# @imqueue — Full documentation > @imqueue is a message-queue RPC framework for Node.js and TypeScript back-ends: services describe themselves at runtime, so their typed clients are generated, not written. This file concatenates the framework documentation as a single markdown document for AI ingestion. Canonical HTML: https://imqueue.org/. Commercial licensing: https://imqueue.com/. Roughly 550 KB / 137k tokens across 69 documents (the bodies below; this header adds a little). If that does not fit, fetch https://imqueue.org/llms.txt instead and follow the links — every page listed there is also served as plain markdown at `index.md` and at `.md` — byte-identical. Documents appear below in reading order: orientation, then the tutorial, the CLI manual, the MCP server and the agent recipes, then the articles, then the hand-written API guide, with legal pages last. Each is separated by `---` and headed by a `#` title and a `Source:` line carrying its canonical URL. ## Contents 1. @imqueue — TypeScript RPC over a message queue — https://imqueue.org/ 2. @imqueue is a message-queue RPC framework for Node.js and TypeScript back-ends — https://imqueue.org/intro/ 3. Documentation: guides, tutorial & API reference — https://imqueue.org/docs/ 4. Get started: Node.js microservices in minutes — https://imqueue.org/get-started/ 5. @imqueue glossary: every term the docs assume — https://imqueue.org/glossary/ 6. Using AI coding assistants with @imqueue — https://imqueue.org/using-ai-assistants/ 7. @imqueue compared: every alternative, side by side — https://imqueue.org/compare/ 8. Tutorial: build Node.js microservices — https://imqueue.org/tutorial/ 9. Build your first TypeScript RPC service — https://imqueue.org/tutorial/user-service/ 10. Auth service: calling another microservice — https://imqueue.org/tutorial/auth-service/ 11. Domain services: PostgreSQL & in-memory data — https://imqueue.org/tutorial/other-services/ 12. GraphQL API gateway for microservices — https://imqueue.org/tutorial/api-service/ 13. Deploy microservices with Docker & autoscaling — https://imqueue.org/tutorial/deployment/ 14. Bonus: REST & OpenAPI gateway with Swagger UI — https://imqueue.org/tutorial/rest-api/ 15. Bonus: React front-end on a REST gateway — https://imqueue.org/tutorial/rest-web-app/ 16. CLI User Guide — https://imqueue.org/cli/ 17. Installation — https://imqueue.org/cli/installation/ 18. Configuration — https://imqueue.org/cli/configuration/ 19. Creating Services — https://imqueue.org/cli/creating-services/ 20. Package Catalog — https://imqueue.org/cli/package-catalog/ 21. Providers — https://imqueue.org/cli/providers/ 22. Clients & Versioning — https://imqueue.org/cli/clients-and-versioning/ 23. Managing Local Services — https://imqueue.org/cli/managing-local-services/ 24. Custom Templates — https://imqueue.org/cli/custom-templates/ 25. Extensibility — https://imqueue.org/cli/extensibility/ 26. Real-World Scenarios — https://imqueue.org/cli/real-world-scenarios/ 27. Troubleshooting — https://imqueue.org/cli/troubleshooting/ 28. MCP server for AI coding agents — https://imqueue.org/mcp/ 29. Add the MCP server to Claude, Cursor & VS Code — https://imqueue.org/mcp/installation/ 30. MCP tools reference — https://imqueue.org/mcp/tools/ 31. MCP agent workflows — https://imqueue.org/mcp/workflows/ 32. MCP safety model & troubleshooting — https://imqueue.org/mcp/security/ 33. Agent recipes for @imqueue codebases — https://imqueue.org/agents/ 34. Agent recipe: delayed & scheduled work — https://imqueue.org/agents/delayed-scheduled-work/ 35. Agent recipe: isolated imq CLI environments — https://imqueue.org/agents/isolated-imq-environments/ 36. @imqueue vs NATS: a framework and a transport are not the same choice — https://imqueue.org/blog/imqueue-vs-nats/ 37. How Node.js services talk to each other in 2026: the honest options — https://imqueue.org/blog/nodejs-service-communication-options-2026/ 38. One notification, every replica: the LISTEN/NOTIFY duplicate problem — https://imqueue.org/blog/postgres-notify-duplicate-listeners/ 39. Graceful shutdown and zero-drop deploys — https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/ 40. Delayed and scheduled work without adding a job system — https://imqueue.org/blog/scheduled-work-without-a-job-system/ 41. Auto-scaling Redis broker: with and without broadcast — https://imqueue.org/blog/horizontally-scalable-redis-broker/ 42. One isolated imq CLI home per @imqueue project — https://imqueue.org/blog/isolated-imq-cli-environments/ 43. RPC over Redis in Node.js: patterns and pitfalls — https://imqueue.org/blog/rpc-over-redis-nodejs/ 44. BullMQ alternatives for Node.js: an honest 2026 guide — https://imqueue.org/blog/bullmq-alternatives/ 45. RPC between Node.js microservices over a message queue — https://imqueue.org/blog/rpc-over-message-queue-nodejs/ 46. Type-safe service-to-service communication in TypeScript — https://imqueue.org/blog/type-safe-service-communication-typescript/ 47. Stop hand-writing and maintaining your microservice clients — https://imqueue.org/blog/stop-hand-writing-microservice-clients/ 48. Load balancing microservices without a load balancer — https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/ 49. Do your Node.js back-ends really need service discovery? — https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/ 50. Back-pressure for Node.js services — https://imqueue.org/blog/backpressure-nodejs-services/ 51. Guaranteed message delivery: cost and when to use it — https://imqueue.org/blog/guaranteed-message-delivery-cost/ 52. Cutting the boilerplate out of Node.js microservices — https://imqueue.org/blog/cutting-boilerplate-nodejs-microservices/ 53. Versioning microservices without breaking every caller — https://imqueue.org/blog/versioning-microservices-without-breaking-callers/ 54. Testing services that call each other — https://imqueue.org/blog/testing-microservices-without-the-whole-stack/ 55. Internal APIs don't need to be REST — https://imqueue.org/blog/internal-apis-dont-need-rest/ 56. From monolith to services: a low-risk first extraction — https://imqueue.org/blog/monolith-to-services-first-extraction/ 57. Redis as a message bus: patterns beyond pub/sub — https://imqueue.org/blog/redis-message-bus-patterns/ 58. @imqueue vs Moleculer: two takes on Node.js microservices — https://imqueue.org/blog/imqueue-vs-moleculer/ 59. @imqueue vs NestJS microservices: framework vs transport — https://imqueue.org/blog/imqueue-vs-nestjs/ 60. tRPC vs @imqueue: client–server types vs service–service RPC — https://imqueue.org/blog/imqueue-vs-trpc/ 61. @imqueue/job vs BullMQ: Redis job queues compared — https://imqueue.org/blog/imqueue-vs-bullmq/ 62. gRPC vs message-queue RPC for internal Node.js services — https://imqueue.org/blog/grpc-vs-message-queue-rpc/ 63. Benchmarking @imqueue: throughput and delivery modes — https://imqueue.org/blog/benchmarking-imqueue-throughput/ 64. GPL-3.0 open-source license terms — https://imqueue.org/license/ 65. Support — https://imqueue.org/support/ 66. Contributing & contribution terms — https://imqueue.org/contributing/ 67. Privacy policy — https://imqueue.org/privacy/ 68. Terms of use — https://imqueue.org/terms/ 69. API guide — https://imqueue.org/api/ --- # @imqueue — TypeScript RPC over a message queue Source: https://imqueue.org/ RPC over a message queue for service-oriented Node.js & TypeScript back-ends. Self-describing services generate their own typed clients — no boilerplate. ## What @imqueue is A message-queue RPC framework for Node.js and TypeScript microservices. One back-end service calls another as if it were a local *typed* function, while the call itself travels over a Redis-backed message queue instead of HTTP. A service is a class with [exposed methods](https://imqueue.org/api/rpc/latest/rpc.expose/). Because it describes its own signatures at runtime, [the CLI generates the typed client for you](https://imqueue.org/cli/clients-and-versioning/) — so there is no schema file, no IDL, and no hand-written SDK to keep in sync. Because the queue name *is* the address, instances of a service compete for messages. That removes two moving parts most microservice stacks need: [service discovery](https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/) and an [internal load balancer](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/). A whole service, and the command that gives its callers a typed client: ~~~typescript // hello.service.ts import { IMQService, expose } from '@imqueue/rpc'; class Hello extends IMQService { @expose() async hello(name?: string) { return `Hello, ${name}!`; } } ~~~ ~~~bash imq client generate Hello ~~~ Measured on the project's own rig: roughly **200,000 round-trip messages/second** with default delivery and **120,000/s** with safe delivery — aggregate across 22 worker processes on one machine, not per core and not latency percentiles. [The benchmark post](https://imqueue.org/blog/benchmarking-imqueue-throughput/) states the rig and the method, so treat the figures as a shape and measure your own workload. ## Reach for it when - Your services are **Node.js and TypeScript** and talk to each other a lot. - You want [compile-time-safe calls](https://imqueue.org/blog/type-safe-service-communication-typescript/) without maintaining an IDL. - You are [pulling a first service out of a monolith](https://imqueue.org/blog/monolith-to-services-first-extraction/). - You want traffic spikes [buffered rather than cascading](https://imqueue.org/blog/backpressure-nodejs-services/). - You need [delayed and scheduled calls](https://imqueue.org/blog/scheduled-work-without-a-job-system/) without adding a job system. ## Look elsewhere when - Your fleet is **polyglot** — [gRPC is the better answer](https://imqueue.org/blog/grpc-vs-message-queue-rpc/). @imqueue is Node and TypeScript only, and ships one transport. - The API is [public or browser-facing](https://imqueue.org/blog/internal-apis-dont-need-rest/), where REST or GraphQL belongs at the edge. - Your types already span one shared TypeScript project — [tRPC's home ground](https://imqueue.org/blog/imqueue-vs-trpc/). - You want a full application framework rather than a transport: [NestJS](https://imqueue.org/blog/imqueue-vs-nestjs/) or [Moleculer](https://imqueue.org/blog/imqueue-vs-moleculer/). - You need exactly-once delivery. Delivery here is **at-least-once** in both modes — safe delivery covers the hand-off only, so [handlers must be idempotent](https://imqueue.org/blog/guaranteed-message-delivery-cost/). ## How it works 1. **Implement a service** — a class with exposed methods and doc-blocks. 2. **Generate the client** — services are self-describing, so clients are generated on the fly or written to files. 3. **Call it remotely** — await a client method like any local call; the queue routes it and returns the result. ## Start here - [Get started](https://imqueue.org/get-started/) — install the CLI, scaffold a service, generate its client. - [Introduction](https://imqueue.org/intro/) — the architecture and the principles behind it. - [Tutorial](https://imqueue.org/tutorial/) — build a complete car-wash booking back-end, service by service. - [Documentation](https://imqueue.org/docs/) — every section, including the CLI manual. - [API reference](https://imqueue.org/api/) — generated reference for all 16 documented packages. - [Commercial licence & support](https://imqueue.com/) — GPL-3.0 is the open-source licence; a commercial licence covers closed-source use. ## If you are an AI agent - [/llms.txt](https://imqueue.org/llms.txt) indexes this site for you; [/llms-full.txt](https://imqueue.org/llms-full.txt) is the documentation concatenated. - Every page is also served as plain markdown at `index.md` and at `.md`, byte-identical — this file is the home page's. - [/api/search-index.json](https://imqueue.org/api/search-index.json) resolves a symbol name to its reference page and flags deprecated members. - [@imqueue/mcp](https://imqueue.org/mcp/) is an official Model Context Protocol server: docs search, service and client scaffolding, and `imq` CLI control as tools. - [Agent recipes](https://imqueue.org/agents/) are procedures written for a machine — the API contract each step relies on, the commands that prove the change took effect, and the known failure modes. --- # @imqueue is a message-queue RPC framework for Node.js and TypeScript back-ends Source: https://imqueue.org/intro/ Services describe themselves at runtime, so their typed clients are generated, not written. The name is short for **I**ntercommunication **M**essaging **Queue** — @imqueue, imqueue and IMQ all refer to this framework. It is built with Node.js and TypeScript for service-oriented back-ends (microservices being a special case of SOA), works well behind an API layer such as GraphQL or a REST gateway, and lets you write only the functionality while @imqueue handles the low-level messaging. ## @imqueue at a glance | | | |---|---| | @imqueue/core | 3.3.1 | | @imqueue/rpc | 3.5.2 | | Licence | GPL-3.0-only, or a commercial licence for closed-source distribution | | Node.js | 22.12 or newer | | Redis | 3.2 or newer (6.2+ for safe delivery) | | Transport | Redis only — the vendor option defaults to Redis and is its only supported value | | Addressing | the queue name, which is the service class name — no discovery | | Load balancing | competing consumers — no balancer, no weighting, no canaries | | Delivery | at-least-once in both modes, so exposed methods should be idempotent | | Streaming | none — request/response only | | Languages | Node.js and TypeScript only | | Contract source | the service class plus its JSDoc; clients are generated from a running service | ## Key capabilities - **@imqueue/core** — messaging queue as the base communication protocol between services. [Reference](https://imqueue.org/api/core/latest/) - **@imqueue/rpc** — an RPC-like programming interface over the messaging queue. [Reference](https://imqueue.org/api/rpc/latest/) - **@imqueue/cli** — a rapid application development command line interface. [Manual](https://imqueue.org/cli/) Thirteen more packages cover caching, observability, API composition, background work and hardening — all with generated references under [/api/](https://imqueue.org/api/). ## Key principles - **Reliable.** Safe-delivery messaging re-queues a message a dying worker never started, rather than losing it with the process. The guarantee covers that hand-off: delivery is [at-least-once](https://imqueue.org/api/core/latest/core.imqoptions.safedelivery/), so handlers should be idempotent. - **Scalable.** Cluster the backend engine, fork across a machine's cores, and scale horizontally across servers — throughput grows with the workers you add, and [what that looks like on one rig is measured here](https://imqueue.org/blog/benchmarking-imqueue-throughput/). - **Simple (KISS).** Low entry for JS/TypeScript developers — minutes to your first service. No hidden knowledge, a clean JSON-based protocol, and familiar patterns (Messaging Queue and RPC). - **Self-describing.** Every service describes itself, so clients are generated dynamically on the fly or pre-generated to files. You focus only on the service. ## How it works ### A centralized broker IMQ implements a messaging queue over a broker (Redis today, adapters are pluggable) that routes messages between services and their clients. All messages flow through a single point, so monitoring and debugging use the tooling the broker already gives you. No service discovery to implement — instances compete for their messages. If one is busy or down, another consumes the message and delivers the response anyway. Load-balancing happens naturally, with good distribution across nodes. ### Service & client model From a development point of view a service is as simple as a class with exposed methods. A client is a local representation of that remote service's interface. Call a client method and it takes care of delivering the message to the queue, invoking the matching service method, and returning the result. At development level it simply looks like remote procedure calls — and clients are generated for you, so you focus only on the service. ## Next - [Get started in a few minutes](https://imqueue.org/get-started/) - [Tutorial](https://imqueue.org/tutorial/) — a complete example application - [Documentation](https://imqueue.org/docs/) — every section --- # Documentation: guides, tutorial & API reference Source: https://imqueue.org/docs/ Everything you need to build, run and scale @imqueue services — from a two-minute quickstart to the full API reference. ## Sections 1. [Getting Started](https://imqueue.org/get-started/) — install the CLI, scaffold a service and generate its client: your first @imqueue service in a few minutes. 2. [Tutorial](https://imqueue.org/tutorial/) — build a complete example app, one service at a time, up to a GraphQL API and production deployment, then front the same fleet with REST. 3. [CLI User Guide](https://imqueue.org/cli/) — scaffold, generate and run services with the `imq` RAD CLI: configure it, wire up VCS/CI/registry providers, and drive a whole local fleet. 4. [API Reference](https://imqueue.org/api/) — the RPC and Messaging APIs explained (services, clients, decorators and adapters), plus the generated reference for every documented package. 5. [Using AI Assistants](https://imqueue.org/using-ai-assistants/) — a paste-ready context block for Claude, ChatGPT, Cursor and Copilot, plus the machine-readable docs endpoints (`llms.txt`, markdown mirrors) agents can fetch. 6. [MCP Server](https://imqueue.org/mcp/) — wire @imqueue into Claude, Cursor, VS Code, JetBrains and more: the `@imqueue/mcp` server gives an agent live docs search, service scaffolding and CLI control as tools. 7. [Agent Recipes](https://imqueue.org/agents/) — machine-oriented procedures an AI assistant can follow to make a change: the contracts each relies on, commands that prove it worked, and the failure modes to expect. ## Also on this site - [Introduction](https://imqueue.org/intro/) — what the framework is and the principles behind it. - [Blog](https://imqueue.org/blog/) — guides and comparisons, including how @imqueue differs from gRPC, tRPC, NestJS, Moleculer and BullMQ. - [/llms.txt](https://imqueue.org/llms.txt) — this site indexed for AI agents. --- # Get started: Node.js microservices in minutes Source: https://imqueue.org/get-started/ Published: 2026-07-19 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) The shortest path from an empty terminal to a running @imqueue service and a generated client. For a deeper, worked example see the Tutorial; the full technical reference lives in the API docs. ## Prerequisites Before you begin, make sure the following are installed and available on your system: - [Node.js](https://nodejs.org/en/) **22.12 or newer** — we recommend installing it through [NVM](https://github.com/nvm-sh/nvm#installing-and-updating). - [Redis](https://redis.io/download) — version 3.2 or newer. @imqueue uses Redis as its message-queue transport. - [Git](https://git-scm.com/downloads) — the command-line client. ## 1. Install the CLI Install the @imqueue command-line tool globally. It scaffolds services and generates clients for you, so you write features instead of boilerplate: ~~~bash npm i -g @imqueue/cli ~~~ On first run the installer offers to collect some initial configuration. You can fill it in now, or press `Ctrl+C` to skip and configure it later (or not at all — it is optional). ## 2. Configure (optional) `@imqueue/cli` works without any configuration. Defining a global configuration once is only worthwhile on larger projects with many services, where it saves you from repeating the same options on every command. To create or re-create the configuration at any time, run: ~~~bash imq config init ~~~ For the full setup details — requirements, upgrading and shell completions — see the [Installation](https://imqueue.org/cli/installation/) & [Configuration](https://imqueue.org/cli/configuration/) chapters of the CLI User Guide. ## 3. Enable shell completions Turning on completions for the `imq` command makes the CLI far more pleasant to use. Run: ~~~bash imq completions on ~~~ and follow the prompts. `bash` and `zsh` are supported. ## 4. Everyday usage The CLI exists to remove the boilerplate of building `@imqueue`-based back-end services. It does two main jobs for you: 1. Scaffold services from ready-made templates. 2. Generate client code for calling those services. ### 4.1 Create a service Scaffold a new service into a fresh directory: ~~~bash mkdir user-service cd user-service imq service create ~~~ Then open `src/UserService.ts` and implement the methods your service needs to expose. A complete, working service looks like this: ~~~typescript // src/UserService.ts import { IMQService, expose, profile } from '@imqueue/rpc'; import { UserObject } from './types/UserObject.js'; export class UserService extends IMQService { private users: UserObject[] = [ Object.assign(new UserObject(), { id: '42', email: 'jane@example.com', firstName: 'Jane', lastName: 'Doe', isActive: true, }), ]; /** * Returns a user by identifier, or null when there is no such user * * @param {string} id - user identifier * @return {Promise} - the matching user */ @profile() @expose() public async get(id: string): Promise { return this.users.find(user => user.id === id) || null; } /** * Returns how many users are active * * @return {Promise} - the number of active users */ @profile() @expose() public async countActive(): Promise { return this.users.filter(user => user.isActive).length; } } ~~~ Three things in there are the whole contract, and getting any of them wrong is the usual reason a generated client comes out empty: - **`extends IMQService`** — that is what makes the class a service and gives it `start()`, `destroy()` and `this.logger`. - **`@expose()`** on every method that should be callable remotely. A method without it is a normal method and stays private to the service. - **A doc-block on each exposed method.** The client generator reads it, so the parameter and return descriptions above become the documentation your callers see in their IDE. `@profile()` is optional and independent — it measures and logs execution time for the method it decorates. Reach for it on the parts of the system you most want to keep an eye on. Every value crossing the queue is JSON, so **a complex type must be declared as a class** with `@classType()` on the class and `@property()` on each exposed field. That is what lets both the service and the generated client agree on its shape: ~~~typescript // src/types/UserObject.ts import { classType, property } from '@imqueue/rpc'; /** * Serializable user type */ @classType() export class UserObject { @property('string') id: string; @property('string') email: string; @property('string') firstName: string; @property('string') lastName: string; @property('boolean') isActive: boolean; // Optional — pass true as the second argument @property('string', true) nickname?: string; } ~~~ `@property()`'s first argument is the type in TypeScript notation, and it may name another complex type (`'UserCarObject'`) or an array of one (`'UserCarObject[]'`). Types declared this way arrive on the client side as TypeScript interfaces. Returning a plain object literal instead works at runtime but describes nothing, so the generated client types the field as `any`. ### 4.2 Run the service Make sure a Redis server is running on the default port, then start the service in watch mode: ~~~bash npm run dev ~~~ ### 4.3 Generate a client Every @imqueue service is self-describing, so a fully typed client can be generated directly from a running service: ~~~mermaid flowchart LR A["UserService.ts with @expose() + JSDoc"] -->|"npm run dev"| B["running service on queue UserService"] B -->|"describes itself over the queue"| C["imq client generate UserService ./src/clients"] C --> D["src/clients/UserService.ts exporting UserClient"] D -->|"tsc"| E["caller: await client.get('42'), fully typed"] ~~~ The JSDoc-annotated `@expose()`d methods are @imqueue's only contract, so there is no schema file to keep in step — but generation does need the service *running*. Leave `npm run dev` running in the first terminal, and in a second one: ~~~bash imq client generate UserService ./src/clients ~~~ The service must be **up**, with Redis reachable — generation works by asking the running service to describe itself, so there is no schema file and no IDL to keep in sync. It writes `src/clients/UserService.ts` (and its compiled `.js`), exporting a client class named after the service with a trailing `Service` replaced by `Client` — so `UserService` gives you `UserClient`. Now call it. This is a complete, runnable consumer: ~~~typescript // consumer.ts — in the service root, next to package.json import { UserClient } from './src/clients/UserService.js'; const client = new UserClient(); await client.start(); try { // Fully typed: `user` is UserObject | null, and `user.firstName` completes // in the IDE. No hand-written client, no service discovery, no HTTP. const user = await client.get('42'); console.log(user?.firstName, await client.countActive()); } finally { // Closes the client's Redis channels. Without it the process will not exit. await client.destroy(); } ~~~ `npm run dev` runs the service's own entry point, not this file, so compile and run it directly — the same way the Tutorial runs its `debug.ts`: ~~~bash npm run build && node consumer.js ~~~ ~~~ Jane 1 ~~~ Two things worth knowing before you scale this up: - **The queue name is the address.** Start a second copy of `UserService` and the two instances compete for messages on the same queue, so calls are shared between them. That is why there is no service registry and no internal load balancer to run — see [service discovery](https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/) and [load balancing](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/). - **Regenerate the client when the service's interface changes.** The generated file is a build artefact, not something to edit; `-o` overwrites it without prompting. See [Clients & versioning](https://imqueue.org/cli/clients-and-versioning/).

That's it — you've built and called your first @imqueue service.

Ready for more? Work through the Tutorial for a complete example application, or dive into the API reference.

--- # @imqueue glossary: every term the docs assume Source: https://imqueue.org/glossary/ Published: 2026-08-03 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Short definitions of the vocabulary the rest of these docs use without stopping to explain — IMQ, fleet, self-describing service, safe delivery, provider axis — each with a link to the page that covers it properly. Every project accumulates words it stops explaining. This page collects the ones `@imqueue` uses, so a term you meet halfway through the tutorial or in a method signature has one short answer and one authoritative link. ## The framework ### IMQ IMQ is the abbreviation of **Inter-Communication Messaging Queue**, the phrase the project's name is built from. `@imqueue`, `imqueue`, `IMQ` and "I Message Queue" all refer to the same framework: a message-queue RPC framework for Node.js and TypeScript back-ends. The `IMQ` prefix also names most of its exported types — `IMQService`, `IMQClient`, `IMQOptions`, `IMQDelay`. See [what @imqueue is](https://imqueue.org/intro/) for the longer answer. ### Service A service is a TypeScript class extending [`IMQService`](https://imqueue.org/api/rpc/latest/rpc.imqservice/) that consumes messages from one named queue. The class name *is* the queue name and therefore the service's whole address — there is no host, port or path to configure, and no registry to publish to. Run several copies of the same class and they become competing consumers of that one queue. ### Client A client is the object a caller holds to invoke another service's methods. It looks like an ordinary typed object — `await client.get('42')` — and underneath it publishes a request message to the target service's queue and waits for the reply on its own. Clients come in two forms, generated and dynamic. ### Generated client A generated client is a TypeScript source file written by [`imq client generate [path]`](https://imqueue.org/cli/clients-and-versioning/) from a **running** service, then committed and compiled like any other code. It is a build output: regenerating overwrites it silently, so customisations belong in a wrapper. Because the file is an artifact rather than an import across a project boundary, the caller can live in a different repository and release on its own schedule. ### Dynamic client A dynamic client is built at runtime by [`IMQClient.create('User', …)`](https://imqueue.org/api/rpc/latest/rpc.imqclient.create/), which asks the running service for its description and constructs the client from it. It needs the target service to be up at construction time — which is the one place `@imqueue`'s otherwise order-independent boot does not hold. ### Self-describing service A service is self-describing because it can report its own interface — classes, methods, signatures and complex types — on request. That report is what makes client generation possible without a separate schema file: the implementation is the single source of truth, and there is no `.proto` or IDL to keep in step with it. ### Service description The description is the metadata a service reports about itself, retrievable from any client by calling [`describe()`](https://imqueue.org/api/rpc/latest/rpc.imqclient.describe/). It is assembled from the `@expose()`-marked methods and their **JSDoc** — not from TypeScript's own types, which do not survive to runtime. That is why the doc-block is mandatory rather than decorative. ### `@expose()` [`@expose()`](https://imqueue.org/api/rpc/latest/rpc.expose/) is the decorator marking a service method as remotely callable. An undecorated method is ordinary internal code and appears in no description and no generated client. Every `@expose()`d method needs a complete JSDoc block: an unannotated parameter is typed `any` in the generated client, and a `@param` count that disagrees with the real arity is a defect the generator cannot see. ### `@classType()` and `@property()` [`@classType()`](https://imqueue.org/api/rpc/latest/rpc.classtype/) marks a class as a complex type that may cross the RPC boundary, and [`@property()`](https://imqueue.org/api/rpc/latest/rpc.property/) declares each of its fields. Both are required for the type to reach a generated client with its shape intact — without them the field is typed `any`, which compiles fine and quietly removes the guarantee the framework exists to provide. ## Delivery and distribution ### Competing consumers Competing consumers is the distribution pattern where several instances read the same queue and each message goes to exactly one of them. Nothing chooses an instance: an instance takes the next message when it is ready for one, so "who is free" is expressed by the act of asking rather than estimated by a policy. It is how `@imqueue` balances load without a load balancer, and it has no notion of weighting, so canary routing is not expressible. See [load balancing without a load balancer](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/). ### At-least-once delivery At-least-once means a message may be delivered more than once and will not silently be delivered zero times. `@imqueue` is at-least-once in **both** delivery modes, so exposed methods should be idempotent. Reads usually already are; writes need a natural key or a dedupe check. ### Safe delivery Safe delivery is the optional mode ([`safeDelivery`](https://imqueue.org/api/core/latest/core.imqoptions.safedelivery/), off by default) that leases a message to a worker instead of handing it over outright, so a message a dying worker never *started* is re-queued rather than lost with the process. It is narrower than "guaranteed": it does not protect work already in flight, and nothing in the framework drains in-flight work on shutdown. It costs throughput — roughly 120K vs 200K round-trips/second on the project's own rig. See [what guaranteed delivery really costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/). ### `IMQDelay` [`IMQDelay`](https://imqueue.org/api/rpc/latest/rpc.imqdelay/) is the value that schedules a call for later instead of now, passed as the **last** argument to a client method: `client.update({ … }, undefined, new IMQDelay(1, 'h'))`. Delayed messages are how `@imqueue` covers scheduled work without a separate job system. ### `callTimeout` `callTimeout` is the client-side deadline after which a pending call rejects with `IMQ_RPC_CALL_TIMEOUT`. It is **unset by default**, so a call to a service with no consumer waits forever — set it explicitly in production. It is a caller-side timeout only: no signal reaches the service, which never saw a deadline and keeps working. That is the sharpest difference from gRPC, whose deadlines propagate. ## The CLI and tooling ### Fleet A fleet is the set of `@imqueue` services that make up one system and are developed, run and released together. It is not a framework construct — there is no fleet object and no registry — but it is the unit [`@imqueue/cli`](https://imqueue.org/cli/) operates on: `imq ctl` starts and stops a fleet locally, `imq log` tails all of it at once, `imq up` bulk-updates dependencies across it, and `imq service update-version` rolls a version change through it. See [managing local services](https://imqueue.org/cli/managing-local-services/). ### Provider axis `imq service create` composes four independent axes through a typed provider registry: the **template** the service is scaffolded from, the **VCS** host the repository is created on, the **CI** provider whose config and secrets are wired, and the container **registry** images are pushed to. Each axis is chosen by its own flag and each has its own credential requirements, so an unusual combination is a configuration rather than a fork. See [providers](https://imqueue.org/cli/providers/). ### Addon package An addon package is a secondary `@imqueue` library that [`imq service create --packages`](https://imqueue.org/cli/package-catalog/) can wire into a new service from a data-driven catalog — caching, tracing, a database layer. Some addons are mutually exclusive: `pg-prisma` and `pg-sequelize` are two answers to the same question, as are `opentelemetry` and `datadog`, and installing both of a pair breaks silently. ### Template manifest The template manifest is the `imq-template.json` file at the root of a service template. It declares the template's version, the `%TOKEN%` substitutions the CLI performs when scaffolding, and the fragment overlays an addon package may merge in. It is what makes a directory of files usable as an `imq` template rather than just a directory of files. See [custom templates](https://imqueue.org/cli/custom-templates/). ### `IMQ_CLI_HOME` `IMQ_CLI_HOME` is the environment variable that relocates the CLI's state directory, normally `~/.imq`. Pointing it at a per-project path gives each fleet its own configuration, credentials and service registry instead of one global set — which is what makes it possible to work on two unrelated `@imqueue` systems on one machine, and what makes the CLI usable in CI. See [one isolated imq CLI home per project](https://imqueue.org/blog/isolated-imq-cli-environments/). ## Adjacent terms, distinguished ### Message queue vs job queue A **message queue** moves messages between processes; what the message *means* is the application's business. A **job queue** additionally owns the lifecycle of a unit of work — retries, backoff, dead-letter handling, progress, scheduling, a dashboard. `@imqueue` is a message queue with an RPC layer on top: it gives you typed request/response between services, not job management. [`@imqueue/job`](https://imqueue.org/api/job/latest/) covers the narrow scheduling case; a real job queue such as BullMQ covers the rest. See [BullMQ alternatives](https://imqueue.org/blog/bullmq-alternatives/). ### RPC over a queue vs REST Both carry a call from one service to another. REST addresses a **host** and needs something to decide which instance answers — DNS, a load balancer, a mesh. RPC over a queue addresses a **queue name**, and the instance that answers is whichever one asked for work; if none is running, the message waits instead of failing. The cost is that a browser cannot speak to a queue, so an HTTP front door is still required. See [internal APIs don't need REST](https://imqueue.org/blog/internal-apis-dont-need-rest/). --- If a term you needed is missing, [tell us](https://imqueue.org/contact/) — and if you are an agent reading this, the machine-readable index of the whole site is at [llms.txt](https://imqueue.org/llms.txt). --- # Using AI coding assistants with @imqueue Source: https://imqueue.org/using-ai-assistants/ Published: 2026-07-22 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Build @imqueue services faster with Claude, ChatGPT, Cursor, GitHub Copilot and other coding agents. Paste the context block below into your assistant so it generates correct, idiomatic @imqueue code. ## Why this page exists @imqueue is a small, strongly-typed framework, and coding assistants work best when they have accurate context about its packages, decorators and conventions. This page gives you a **paste-ready context block** and points AI agents at the **machine-readable versions** of these docs. ## Paste this into your AI assistant Copy the block below into Claude, ChatGPT, Cursor, Windsurf, GitHub Copilot Chat or any other assistant before asking it to write @imqueue code. It captures the package names, the core APIs and the constraints that most often trip up generated code. ~~~text You are helping me build back-end services with @imqueue, an RPC framework for Node.js and TypeScript that communicates over a Redis-backed message queue. Packages: - @imqueue/rpc — typed RPC: services, clients, decorators. - @imqueue/core — the underlying message queue over Redis. - @imqueue/cli — scaffolding (`imq service create`) and client generation (`imq client generate `). How a service is written: - A service is a class that extends `IMQService` from '@imqueue/rpc'. - Only methods decorated with `@expose()` are callable remotely. - Exposed-method arguments and return values MUST be JSON-serializable. - Do NOT use the spread/rest operator for exposed-method arguments — the generated client won't compile. Pass an array instead: // wrong: public doThing(...args: any[]) // right: public doThing(args: any[]) - Write doc-blocks with accurate @param/@return types — they are part of the service's self-description and drive the generated client's types. Complex types: - Declare data objects as classes decorated with `@classType()`, and each field with `@property('type', optional?)`, e.g. `@property('string')` or `@property('AddressObject[]', true)` for an optional array. Clients: - Clients are GENERATED from a running service (`imq client generate`), not hand-written. Usage: const client = new UserClient(); await client.start(); const user = await client.update({ ... }); - Every generated method takes two extra optional trailing params, in this order: `imqMetadata?: IMQMetadata`, then `imqDelay?: IMQDelay`. They are stripped by identity, not by position. To delay a call, skip the metadata slot and keep the delay last: client.update({ ... }, undefined, new IMQDelay(1, 'h')); From @imqueue/rpc 3.4.0 a trailing `undefined` on a delayed call is a placeholder and is never delivered. On <= 3.3.0 it travels on as a real argument and the call fails with IMQ_RPC_INVALID_ARGS_COUNT, so on those versions pass a bag instead: client.update({ ... }, new IMQMetadata({}), new IMQDelay(1, 'h')); Passing the delay alone, in the metadata slot, runs but does not type-check on any version — do not silence that error with a cast. - There is no service discovery or load balancer to configure; the queue handles routing. Runtime: - Requires Node.js 22.12+ and Redis 3.2+ (default connection localhost:6379). - Configure host/port/cluster/safeDelivery via IMQServiceOptions or environment. License: the open-source packages are GPL-3.0. Commercial licensing for closed-source products is available at https://imqueue.com. Prefer generating a service class + its typed methods, and let the CLI generate the client. Follow the patterns above exactly. ~~~ ## A minimal service the way @imqueue expects it ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; export class UserService extends IMQService { /** * Returns a user by id * * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { // ...look the user up and return a JSON-serializable value return { id, name: 'Jane Doe' }; } } ~~~ Then generate and use a fully typed client: ~~~bash imq client generate UserService ~~~ ~~~typescript const client = new UserClient(); await client.start(); const user = await client.get('42'); // fully typed, no hand-written client ~~~ ## MCP server: give your agent live docs & scaffolding For agents that speak the [Model Context Protocol](https://modelcontextprotocol.io) (Claude Code, Claude Desktop, Cursor, VS Code, Visual Studio, JetBrains, …), the **`@imqueue/mcp`** server is the best integration. Instead of pasting the context above, your agent gets tools it can call directly — searching these docs live, scaffolding `IMQService` code, and driving the `imq` CLI. **Claude Code:** ~~~bash claude mcp add imqueue -- npx -y @imqueue/mcp ~~~ Most other clients take this in their MCP config: ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ No API keys, no build step — it runs from npm and only ever fetches imqueue.org. → **[Full MCP server documentation](https://imqueue.org/mcp/)**: per-client setup, the complete tools reference, agent workflows and the safety model. ## Endpoints for AI agents If you are building an agent, or your assistant can fetch URLs, these endpoints serve the documentation in machine-friendly form: - **[/llms.txt](https://imqueue.org/llms.txt)** — a curated, machine-readable index of the docs (following the [llmstxt.org](https://llmstxt.org/) convention). - **[/llms-full.txt](https://imqueue.org/llms-full.txt)** — the full documentation concatenated into a single markdown file for one-shot ingestion. - **Markdown mirror of any docs page**, at either of two URL shapes — append `index.md` to the page URL, or replace its trailing slash with `.md`. Both serve the same bytes: [`/get-started/index.md`](https://imqueue.org/get-started/index.md) and [`/get-started.md`](https://imqueue.org/get-started.md) are the same file, as are [`/tutorial/user-service/index.md`](https://imqueue.org/tutorial/user-service/index.md) and [`/tutorial/user-service.md`](https://imqueue.org/tutorial/user-service.md). Stripe and Anthropic's docs use the second shape, Cloudflare's the first; rather than pick, this site answers both. - **[/api/](https://imqueue.org/api/)** — the full generated API reference for every documented `@imqueue` package, and [`/api/search-index.json`](https://imqueue.org/api/search-index.json) to resolve a symbol name to its page. ## Agent recipes For specific tasks, [**/agents/**](https://imqueue.org/agents/) collects procedures written for a machine rather than a reader — each one states the API contracts it depends on, the commands that prove the change took effect, and the failure modes to expect. ## Next steps - Work through the [Getting Started](https://imqueue.org/get-started/) guide. - Follow the [Tutorial](https://imqueue.org/tutorial/) for a complete example application. - Explore the [CLI User Guide](https://imqueue.org/cli/) for scaffolding and fleet management. --- # @imqueue compared: every alternative, side by side Source: https://imqueue.org/compare/ Published: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) One matrix covering every alternative we have written up — gRPC, tRPC, NestJS, Moleculer, NATS, BullMQ and plain REST — with the disqualifying constraints stated first and a link to the detailed comparison for each. ## Start with what disqualifies @imqueue Feature tables are the least useful part of a comparison, because the decision is usually made by a constraint rather than by a score. These rule `@imqueue` out outright, so they belong first: - **Your fleet is not all Node.js and TypeScript.** There are no clients in other languages, and there is no plan to add them. Use gRPC or NATS. - **You cannot run Redis, or you already run a different bus and will not add a second.** `vendor` defaults to `'Redis'` and is currently the only supported value. `IMessageQueue` is the documented seam for another adapter; none ships. - **You need streaming, replay or message retention.** This is a call-and-reply framework, not a log. NATS JetStream or Kafka. - **GPL-3.0 does not work for you and a commercial licence is not an option.** See [licensing](https://imqueue.com/license/) — a non-issue for internal services, a real decision if you distribute software. If none of those apply, the rest of this page is about fit rather than possibility. ## The matrix | | Kind | Languages | Contract comes from | Infra in the call path | Delivery | Licence | Detail | |---|---|---|---|---|---|---|---| | **@imqueue** | RPC framework | Node.js / TS only | **Generated** from the running service | Redis | At-least-once | GPL-3.0 / commercial | — | | **gRPC** | RPC framework | Any (~11 official) | **Declared** in a `.proto` | Load balancer or mesh | At-most-once per attempt | Apache-2.0 | [detail](https://imqueue.org/blog/grpc-vs-message-queue-rpc/) | | **tRPC** | Type-safe RPC | TypeScript only | **Inferred** by the compiler | HTTP server | At-most-once per attempt | MIT | [detail](https://imqueue.org/blog/imqueue-vs-trpc/) | | **NestJS** | Full framework | JS / TS | Yours to assemble | Depends on transport | Depends on transport | MIT | [detail](https://imqueue.org/blog/imqueue-vs-nestjs/) | | **Moleculer** | Full framework | JS-first | Yours to assemble | Broker + registry | Depends on transporter | MIT | [detail](https://imqueue.org/blog/imqueue-vs-moleculer/) | | **NATS** (core) | Messaging system | ~40 | Yours to design | NATS server | **At-most-once** | Apache-2.0 | [detail](https://imqueue.org/blog/imqueue-vs-nats/) | | **NATS JetStream** | Persistence on NATS | ~40 | Yours to design | NATS server + storage | At-least-once | Apache-2.0 | [detail](https://imqueue.org/blog/imqueue-vs-nats/) | | **BullMQ** | Job queue | Node.js | Yours to design | Redis | At-least-once | MIT | [detail](https://imqueue.org/blog/imqueue-vs-bullmq/) | | **REST** over HTTP | Convention | Any | Convention, or OpenAPI you write | LB / discovery / DNS | At-most-once per attempt | n/a | [detail](https://imqueue.org/blog/internal-apis-dont-need-rest/) | Two columns carry most of the weight. **Languages** eliminates more options than anything else, and it is the question teams answer optimistically. "We might add a Go service" is a real constraint if it is true and an excuse if it is not. **Contract comes from** is the axis this framework is actually built on. Four answers exist: declared in a schema you write, inferred by the compiler across a shared build, generated from a running implementation, or established by convention and discovered when it breaks. That last one is where most systems are — not as a mistake, but as a default worth choosing deliberately. ## Not the same category Three rows above are frequently compared with `@imqueue` and should not be, or at least not directly: - **NATS and Kafka are transports.** `@imqueue` is a framework that uses one. The fair pairing is "NATS plus the RPC conventions you write" against "`@imqueue` on Redis" — comparing a transport with a framework flatters whichever one you already prefer. - **BullMQ is a job queue.** Jobs and RPC calls are different shapes: a job is fire-and-forget work with a lifecycle you inspect, a call is a request waiting for a typed answer. `@imqueue/job` exists for the job-shaped half; BullMQ is more capable there and is usually the better pick unless you are already on `@imqueue`. See [BullMQ alternatives](https://imqueue.org/blog/bullmq-alternatives/). - **tRPC solves the front-end-to-back-end problem.** It is close to ideal at it. Comparing it on service-to-service ground is comparing it at the thing it was not built for. ## Where each one is genuinely better Stated as flatly as we can manage: - **gRPC** — anywhere a language boundary is crossed, and anywhere the schema has to be an artefact that outlives every implementation of it. - **tRPC** — a TypeScript front-end calling a TypeScript back-end built alongside it. No code generation at all. - **NestJS** — you want a platform: DI, modules, a large ecosystem, conventions a new hire will already know. - **Moleculer** — you want breadth in one dependency: pluggable transporters, a registry, balancing strategies, circuit breakers, a gateway. - **NATS** — polyglot fleets, event-driven designs, streaming and replay, very high message rates, a permissive licence. - **BullMQ** — background jobs, scheduling, retries with a visible lifecycle. The most capable option in that category. - **REST** — consumers outside your control, and the lowest-friction thing that works absolutely everywhere. ## Where @imqueue is the better choice One case, narrowly: **an all-TypeScript back-end whose services call each other a lot, where the recurring cost is writing and re-writing the client for every service.** That is the cost the generated-from-the-implementation model removes, and the reason there is no schema file, no IDL, no service registry and no internal load balancer in the request path. If the pain you actually feel is deployment, observability, database coupling or an unclear service boundary, none of the options on this page will fix it, and choosing between them is a way of not working on it. ## Reading order - [How Node.js services talk to each other in 2026](https://imqueue.org/blog/nodejs-service-communication-options-2026/) — the neutral survey of all six approaches, if you have not chosen yet. - [Why internal APIs do not need REST](https://imqueue.org/blog/internal-apis-dont-need-rest/) — the argument for RPC over HTTP internally, before any framework is involved. - [RPC over a message queue](https://imqueue.org/blog/rpc-over-message-queue-nodejs/) — the transport model itself. - [Get started](https://imqueue.org/get-started/) — a working typed remote call, with the code. ## FAQ ### What are the alternatives to @imqueue? For typed service-to-service RPC: gRPC (any language, schema-first), tRPC (TypeScript, monorepo-shaped), and full frameworks such as NestJS and Moleculer. For the transport underneath: NATS, Kafka, RabbitMQ or Redis directly. For background jobs rather than calls: BullMQ, pg-boss or `@imqueue/job`. ### Is @imqueue a replacement for gRPC? Only inside an all-Node.js fleet. gRPC's central advantage is a language-neutral schema, and if you cross a language boundary that advantage is decisive. Where every service is TypeScript, a `.proto` is a second type system beside the one the compiler already provides. ### Which option needs the least infrastructure? `@imqueue` needs Redis and nothing else — no service registry, no internal load balancer, no sidecar — because the queue name is the address and instances compete for messages on it. REST needs the fewest new components only if you already run a load balancer and DNS-based addressing, which is the part usually left out of the comparison. ### Can I use @imqueue alongside gRPC or NATS? Yes, and it is a reasonable split: gRPC or NATS where you cross a language boundary, `@imqueue` between your Node services. Decide deliberately which owns a given call path, so you are not operating two RPC layers over the same traffic. ### Does @imqueue work with Kafka or RabbitMQ? Not today. `vendor` defaults to `'Redis'` and is currently the only supported value, with `IMessageQueue` as the documented interface an adapter would implement. If you are committed to Kafka or RabbitMQ, use a framework whose transport is pluggable. --- # Tutorial: build Node.js microservices Source: https://imqueue.org/tutorial/ Published: 2026-07-19 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) A step-by-step guide to building back-end services for a car-washing web application with @imqueue — for those who prefer to learn by example. ## A car-wash booking app In this tutorial we build the back-end for a car-wash booking application, one service at a time, covering the fundamentals of the @imqueue framework along the way. Here is what the finished application looks like to its users:
Register screen Login screen Profile details screen Profile garage screen Time table screen
The complete source code for the tutorial application is available on [GitHub](https://github.com/imqueue-sandbox). ## Architecture Let's say we're building the web application on a React/Relay/GraphQL front-end, served by a GraphQL API endpoint that sits in front of a set of @imqueue-based back-end services. While a front-end team builds the user interface, we focus on the back-end. We split it into small, decoupled services that can be developed in parallel by small teams: - **User service** — manages user data. Stack: Node.js/TypeScript, @imqueue over Redis, MongoDB. - **Auth service** — handles authentication. Stack: Node.js/TypeScript, @imqueue over Redis, JSON Web Tokens. - **Car service** — serves car data. Stack: Node.js/TypeScript, @imqueue over Redis, a static data source cached in a custom in-memory store. - **Time-Table service** — manages the washing schedule: reservations and the station's working-hours options. Stack: Node.js/TypeScript, @imqueue over Redis, PostgreSQL through @imqueue/pg-sequelize. - **API service** — a GraphQL endpoint that orchestrates access to the services above. Stack: Node.js/TypeScript, @imqueue over Redis, graphql, graphql-relay, express, graphql-yoga. > **NOTE.** The GraphQL choice is just that — a choice. In two bonus chapters > at the end of the tutorial we put a [REST/OpenAPI gateway](https://imqueue.org/tutorial/rest-api/) > in front of the very same fleet and give it > [a front-end of its own](https://imqueue.org/tutorial/rest-web-app/) — one that speaks REST > natively rather than imitating Relay — leaving every back-end service > untouched. The high-level architecture looks like this: ## Setting up the toolchain The @imqueue command-line tool can wire its scaffolding into third-party services — a git host (GitHub, GitLab or Bitbucket), a container registry (Docker Hub, Google Artifact Registry, AWS ECR or Azure ACR) and a CI provider (GitHub Actions, CircleCI or Travis). When you create a service with the tool, you can get a ready-made repository, continuous integration and one-command Docker image builds out of the box. So the first step is to install and configure `@imqueue/cli`. ### Prepare the development environment You'll need [Node.js](https://nodejs.org/) 22.12 or newer, ideally installed via [NVM](https://github.com/nvm-sh/nvm#installing-and-updating). You'll also need Redis, MongoDB and PostgreSQL — install them however you prefer, whether via Docker images ([Mongo](https://hub.docker.com/_/mongo/), [Redis](https://hub.docker.com/_/redis/), [PostgreSQL](https://hub.docker.com/_/postgres/)) or directly on your system. ### Install @imqueue/cli These git-host, container-registry and CI integrations are entirely optional. Without them, the tool simply creates local folders and files; you choose which to enable when you configure the tool. If you do want the integrations, prepare your git-host and registry namespaces (a personal account or an organisation) and create a personal access token for your git host — GitHub, for example — granting @imqueue/cli permission to create and write to repositories in that namespace. Then install the tool: ~~~bash npm i -g @imqueue/cli ~~~ Then run the interactive configuration wizard once to finish setting up `@imqueue/cli`: ~~~bash imq config init ~~~ It walks you through your git host, CI provider, container registry and default packages, and stores the answers globally. For the full setup details — requirements, upgrading and shell completions — see the [Installation](https://imqueue.org/cli/installation/) & [Configuration](https://imqueue.org/cli/configuration/) chapters of the CLI User Guide. With that in place, we're ready to create our first service. --- # Build your first TypeScript RPC service Source: https://imqueue.org/tutorial/user-service/ Published: 2018-11-08 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Create your first @imqueue service — the User service — and expose typed methods that other services can call. **An `@imqueue` service is a TypeScript class extending `IMQService`, whose callable methods are marked with `@expose()`; `imq service create ` scaffolds one with its dependencies installed.** This chapter builds the tutorial's first service — `User` — configures its Redis connection through `config.ts`, and generates a typed client for it. We're ready to create our first service. Start by making a project directory to hold all of the tutorial's repositories: ~~~bash mkdir ~/my-tutorial-app cd ~/my-tutorial-app ~~~ ## Creating the service Scaffold the User service with a single command: ~~~bash imq service create user ./user ~~~ If all goes well, you'll have a `./user` directory containing every file the service needs, with all dependencies already installed. ## Configuring the service You can check that it works by running `npm run dev`. This requires Redis running on `localhost` and the default port. If your Redis runs on a different host or port, adjust the configuration first. The configuration file lives at `./user/config.ts`. Redis access can be configured in two ways: a single Redis instance, or a cluster of instances. Which one you choose depends on your scaling needs — configure a cluster for services expected to handle heavy load, and a single instance otherwise. A good practice at this point is to read the configuration from the environment and pass it into the service config, so it can be changed at deployment time without touching code. Here is one way to adapt `config.ts`: ~~~typescript import type { IMQServiceOptions } from '@imqueue/rpc'; import { DEFAULT_IMQ_SERVICE_OPTIONS as opts } from '@imqueue/rpc'; try { // native .env file support; throws when there is no .env file process.loadEnvFile(); } catch { /* no .env file — rely on the process environment */ } export const serviceOptions: Partial = { cluster: (process.env['IMQ_REDIS'] || `${opts.host}:${opts.port}`) .split(',').map((instance: string) => { const [host, port] = instance.split(':'); return { host, port: Number(port) }; }), }; export const USER_DB: string = process.env['USER_DB'] || 'mongodb://localhost/user'; ~~~ The service reads its `.env` file through Node's own `process.loadEnvFile()` — no `dotenv` dependency is needed. `IMQServiceOptions` is a type-only import (the scaffold enables `verbatimModuleSyntax`), so it's imported with `import type`. With that in place, you can put a `.env` file in the service's root directory to set the Redis configuration for your local environment. For example, if Redis is running at `some-redis-special.host:63790`: `.env`: ~~~bash IMQ_REDIS="some-redis-special.host:63790" ~~~ If your Redis runs at `localhost:6379` (the standard default), you can skip this step for now. ## Local environment During development it's convenient to run a service directly in your local environment. Keep in mind, though, that in production the same service will usually run with a different configuration. Reading configuration from environment variables solves this cleanly. Because you may have several projects on your development machine, setting environment variables globally can get awkward — `.env` files avoid that. @imqueue services support `.env` files out of the box: whenever a service needs local configuration, create a `.env` file in its root directory and list the variables you want to read from the environment. These files are never committed, so they stay out of production runs. ## Verifying the service Let's confirm the service is operational: ~~~bash npm run dev ~~~ If everything is fine, you should see output like this: ~~~ User: starting single-worker, pid 27034 Starting clustered redis message queue... User: reader channel connected, host localhost:6379, pid 27034 User: writer channel connected, host localhost:6379, pid 27034 ~~~ That means the service is up and ready. The @imqueue boilerplate always scaffolds a service with remotely callable methods already in place, because a service needs at least one exposed method to start without errors. You get two: `version()`, decorated with `@logged() @lock() @profile() @expose()`, which returns the service's `name`, `version` and `repository` read from `package.json`; and `info()`, which adds the package `description` to that. Once you've implemented your own methods you can keep them or remove them. For now, we'll use `version()` to verify the service. Create a `debug.ts` file in the service's root directory with the following content: ~~~typescript import type { ILogger } from '@imqueue/rpc'; import { IMQClient } from '@imqueue/rpc'; import { User } from './src/index.js'; import { serviceOptions } from './config.js'; const logger: ILogger = serviceOptions.logger || console; new User(serviceOptions).start().then((service: any) => { IMQClient.create('User', { write: false }).then(async (ns: any) => { let client: any; try { client = new ns.UserClient(serviceOptions); await client.start(); console.log(await client.version()); } catch (err) { logger.error(err); } await client.destroy(); await service.destroy(); }); }); ~~~ This starts the service and a client, then makes a remote call to the service's `version()` method. `npm run dev` only runs the service's own entry point, so compile and run this file directly: ~~~bash npm run build && node debug.js ~~~ The output should look like: ~~~ User: starting single-worker, pid 32372 User: reader channel connected, host localhost:6379, pid 32372 User: writer channel connected, host localhost:6379, pid 32372 UserClient-6a4e92f40a6e4d7e8a650c6c44d79ab2-2:client: reader channel connected, host localhost:6379, pid 32372 UserClient-6a4e92f40a6e4d7e8a650c6c44d79ab2-2:client: writer channel connected, host localhost:6379, pid 32372 { name: 'user', version: '1.0.0-0', repository: 'git@github.com:imqueue-sandbox/user.git' } ~~~ That confirms the service works as expected. In development mode, @imqueue watches for file changes with nodemon, so you can simply run the service and start coding. > **NOTE:** any file or folder whose name matches the `debug*` pattern is > ignored by git, so you can use `debug.ts` freely — or adjust your ignore files > if you prefer different behaviour. ## Adding dependencies Adding dependencies works exactly as it does in any Node.js project — just use `npm install`. We chose MongoDB as the data store for this service, so we'll use the `mongoose` package to work with it: ~~~bash npm i --save mongoose ~~~ ## Implementing the service ### Prepare the data store First, define a Mongoose schema for the service. @imqueue imposes no constraints here — do it the usual way. Create `./user/src/schema.ts` (or any path you prefer) with the following content: ~~~typescript import mongoose from 'mongoose'; export const schema = new mongoose.Schema({ email: { type: mongoose.SchemaTypes.String, unique: true, required: true, }, password: { type: mongoose.SchemaTypes.String, required: true, }, isActive: { type: mongoose.SchemaTypes.Boolean, default: true, }, isAdmin: { type: mongoose.SchemaTypes.Boolean, default: false, }, firstName: { type: mongoose.SchemaTypes.String, required: true, }, lastName: { type: mongoose.SchemaTypes.String, required: true, }, cars: { type: [{ carId: { type: mongoose.SchemaTypes.String, required: true, }, regNumber: { type: mongoose.SchemaTypes.String, required: true, }, }], required: false, default: [], }, }); ~~~ By design, we store the following for each user: - identifier - first name - last name - email - password - `isActive` flag — lets us block a user for any reason - `isAdmin` flag — marks users with the admin role - the user's cars ("garage") — a dedicated Car service will manage the cars database, but here we store the fields needed to link a user to a car and to hold user-specific data such as the car's registration number Next we implement the operations on that data that remote clients can call — the service's public methods. Open `./user/src/User.ts`, which contains our service class. ### Prepare the database connection Import the Mongoose schema at the top of the file: ~~~typescript import { schema } from './schema.js'; ~~~ We then need to open the MongoDB connection when the service starts and register the schema so we can use it. Declare these properties on the service class: ~~~typescript private db: mongoose.Connection; private UserModel: mongoose.Model; ~~~ Opening a database connection is asynchronous, so the natural place to do it is by overriding `IMQService.start()`. First, add a private `initDb()` method. The connection string comes from `config.ts` (the `USER_DB` value we added earlier, which defaults to `mongodb://localhost/user`), so import it at the top of the file: ~~~typescript import { USER_DB } from '../config.js'; ~~~ ~~~typescript /** * Initializes the MongoDB connection and the user schema * * @return {Promise} */ @profile() private async initDb(): Promise { await mongoose.connect(USER_DB); this.db = mongoose.connection; this.UserModel = mongoose.model('User', schema); } ~~~ Now override `start()` to call it: ~~~typescript /** * Overrides start() to establish the MongoDB connection first */ @profile() public async start(): Promise { this.logger.log('Initializing MongoDB connection...'); await this.initDb(); return super.start(); } ~~~ ### A note on logging Notice we used `this.logger` above. By default it's the standard `console`, but you can swap in your own logger through `config.ts`, and every debug, log and error output across the service will go through it. That's useful when you want to route logs to a specific destination — for example, using `winston` with transports that send output to local storage and/or a remote service such as Sentry. Using `this.logger` (rather than `console` directly) keeps all logging manageable and monitorable from one place. With that, we're ready to implement the service's remote interface. ### Exposing the interface Let's start by defining the external interface — the set of methods the service will expose: ~~~typescript /** * Creates or updates an existing user with the given data * * @param {UserObject} data - user data fields * @param {string[]} [fields] - fields to return on success * @return {Promise} - the saved user object */ @profile() @expose() public async update(data: UserObject, fields?: string[]): Promise { // TODO: implement... return null; } /** * Looks up and returns a user by e-mail or by object identifier * * @param {string} criteria - user identifier or e-mail * @param {string[]} [fields] - fields to select and return * @return {Promise} - the matching user, or null */ @profile() @expose() public async fetch(criteria: string, fields?: string[]): Promise { // TODO: implement... return null; } /** * Returns a collection of users matching the given criteria. Records can be * paginated using the skip and limit arguments. * * @param {UserFilters} [filters] - criteria to filter the user list * @param {string[]} [fields] - fields to select for each returned user * @param {number} [skip] - number of records to skip before fetching * @param {number} [limit] - maximum number of records to return * @return {Promise} - the matching users */ @profile() @expose() public async find(filters?: UserFilters, fields?: string[], skip?: number, limit?: number): Promise { // TODO: implement... return []; } /** * Returns the number of users matching the given criteria * * @param {UserFilters} [filters] - criteria to filter by * @return {Promise} - the number of matching users */ @profile() @expose() public async count(filters?: UserFilters): Promise { // TODO: implement... return 0; } /** * Attaches a new car to a user * * @param {string} userId - identifier of the user to attach the car to * @param {string} carId - identifier of the selected car * @param {string} regNumber - car registration number * @param {string[]} [selectedFields] - fields to return for the modified user * @return {Promise} - the modified user */ @profile() @expose() public async addCar(userId: string, carId: string, regNumber: string, selectedFields?: string[]): Promise { // TODO: implement... return null; } /** * Removes a car from a user * * @param {string} carId - identifier of the user's car * @param {string[]} [selectedFields] - fields to return for the modified user * @return {Promise} - the modified user */ @profile() @expose() public async removeCar(carId: string, selectedFields?: string[]): Promise { // TODO: implement... return null; } /** * Returns a given user's car, fetched by identifier * * @param {string} userId - user identifier * @param {string} carId - car identifier * @return {Promise} */ @profile() @expose() public async getCar(userId: string, carId: string): Promise { // TODO: implement... return null; } /** * Returns the number of cars registered for the user with the given id or email * * @param {string} idOrEmail - user identifier or e-mail * @return {Promise} */ @profile() @expose() public async carsCount(idOrEmail: string): Promise { // TODO: implement... return 0; } ~~~ There are a few mandatory rules for defining externally callable methods: 1. To make a method callable remotely, wrap it with the `@expose()` decorator. 2. Every service must have at least one externally callable method. 3. Write a doc-block for each exposed method. Two rules apply: - Describe all argument and return-value types in TypeScript notation. - Mark optional arguments as optional by wrapping the name in `[]`, like this: `@param {string} [name]`. Following these rules guarantees a correct service description and, in turn, correctly generated, working clients. With the interface above in place, the service won't compile yet — we've referenced types TypeScript doesn't know: `UserObject`, `UserCarObject` and `UserFilters`. Let's define them. ### Defining exposable complex types You can describe complex data structures inline in doc-blocks using TypeScript notation, but that quickly leads to duplication. A cleaner approach is to define reusable complex types. Complex types that can be exposed remotely **must be defined as classes**. Each such class must be annotated with the `@classType()` class decorator, and each exposed field with the `@property()` decorator. The scaffold builds with TypeScript's `experimentalDecorators` and emitted decorator metadata (via `reflect-metadata`): `@property()` collects each field's metadata, and the class-level `@classType()` registers that metadata as a named type, so both the service and the generated client recognise it. Create the first type: ~~~bash mkdir ./user/src/types touch ./user/src/types/UserObject.ts ~~~ Put the following inside: ~~~typescript import { classType, property } from '@imqueue/rpc'; import type { UserCarObject } from './UserCarObject.js'; /** * Serializable user type */ @classType() export class UserObject { @property('string', true) _id?: string; @property('string') email: string; @property('string') password: string; @property('boolean') isActive: boolean; @property('boolean') isAdmin: boolean; @property('string') firstName: string; @property('string') lastName: string; @property('UserCarObject[]') cars: UserCarObject[]; } ~~~ A few things to note about `@property()`: - Its first argument is the property type in TypeScript notation. - Pass `true` as the second argument to mark the property as optional. - A property may reference another complex type — here, `cars` references an array of `UserCarObject`. - Types defined this way appear on the client side as TypeScript interfaces, giving you full type-checking across the client and service. - Leaving a property undecorated hides it from the remote interface, which is a handy way to keep service-internal fields private. Now define the remaining types. ~~~bash touch ./user/src/types/UserCarObject.ts ~~~ ~~~typescript import { classType, property } from '@imqueue/rpc'; @classType() export class UserCarObject { @property('string') _id: string; @property('string') carId: string; @property('string') regNumber: string; } ~~~ And `UserFilters`: ~~~bash touch ./user/src/types/UserFilters.ts ~~~ ~~~typescript import { classType, property } from '@imqueue/rpc'; @classType() export class UserFilters { @property('string', true) email?: string; @property('boolean', true) isActive?: boolean; @property('boolean', true) isAdmin?: boolean; @property('string', true) firstName?: string; @property('string', true) lastName?: string; } ~~~ Finally, import the types into the service class module: ~~~typescript import { UserCarObject } from './types/UserCarObject.js'; import { UserFilters } from './types/UserFilters.js'; import { UserObject } from './types/UserObject.js'; ~~~ The service should now compile without errors. All that's left is to implement the logic of the service methods — we'll leave that as homework, but you can always refer to the [source code](https://github.com/imqueue-sandbox/user) on GitHub. Next up: [Auth Service — inter-service communication](https://imqueue.org/tutorial/auth-service). --- # Auth service: calling another microservice Source: https://imqueue.org/tutorial/auth-service/ Published: 2018-11-08 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Add an Auth service that talks to the User service to log users in and verify them — your first inter-service communication. **One `@imqueue` service calls another by holding that service's client: no HTTP address and no discovery, because the queue name is the address.** This chapter adds an `Auth` service that logs users in and verifies JWTs by calling the `User` service through a client built with `IMQClient.create('User', …)`. Create a new service named `Auth`, the same way we created the User service in the [previous chapter](https://imqueue.org/tutorial/user-service#creating-the-service). Now define the Auth service's interface: ~~~typescript /** * Logs a user in * * @param {string} email - user e-mail address * @param {string} password - user plain-text password * @return {Promise} - the issued auth token, or null if authentication failed * @throws {Error} - "Password mismatch" or "Blocked" */ @profile() @expose() public async login(email: string, password: string): Promise { // TODO: implement... return null; } /** * Logs a user out * * @param {string} token - the user's JWT auth token * @param {string} [verifyEmail] - e-mail to verify against the token (if provided, must match) * @return {Promise} - operation result */ @profile() @expose() public async logout(token: string, verifyEmail?: string): Promise { // TODO: implement... return true; } /** * Verifies whether a token is valid and, if so, returns the associated user * * @param {string} token - the user's auth token * @return {Promise} - the associated user, or null */ @profile() @expose() public async verify(token: string): Promise { // TODO: implement... return null; } ~~~ So the service does three things: - sign users in - sign users out - verify whether a given auth token is valid To do this, the Auth service needs to talk to the User service: `login()` has to check the supplied credentials, and `verify()` returns the verified user object. > **A word of caution.** > Think twice before letting one service call another directly. In a system > where many services talk to each other point-to-point, the overall data flow > can quickly become hard to reason about. Look for a more predictable way to > organise communication first, and reach for direct calls only when they're > genuinely warranted. @imqueue doesn't dictate how you structure service > communication — that architectural decision is yours to make wisely. We use a > direct call here specifically so you can get hands-on with the feature and see > how it works. ## The service client To call a remote service in the RPC pattern, you need a client. Because every @imqueue service is self-describing, there are three ways to build one: - **Dynamically, at runtime.** The client is built while your program runs. The remote service must be up, because @imqueue asks it for its interface description and generates the client on the fly. This is convenient — you don't have to track interface changes yourself — but it has trade-offs: you lose version control over your clients, along with compile-time type-checking and IDE auto-completion during development. You also have to start services in the right order, since a client can't be built while its service is unavailable. - **Statically, ahead of time.** The @imqueue/cli tool generates client source code from a running service. You'll need to regenerate whenever the service's interface changes, but in return you get type-checking, IDE auto-completion, and the ability to detect and version interface changes. Keeping all generated clients in one place makes this the best choice for most applications — and you no longer need to worry about service start-up order, since clients can be instantiated without their services running. - **Manually.** For special cases you can write client code yourself, adding whatever custom behaviour you need. @imqueue provides the `IMQClient` base class to extend. This is advanced usage and outside the scope of this tutorial. For this chapter, let's integrate the Auth service with the User service using a **dynamically generated** client for User. That means we'll initialise a User client asynchronously during the Auth service's start-up — much as we established the database connection in the previous chapter. Override the Auth service's `start()` method: ~~~typescript class Auth extends IMQService { private user: any; // a dynamic client gives us no compile-time types! /** * Performs the required async preparations on service initialization */ public async start() { this.user = new (await IMQClient.create('User', { write: false })).UserClient(); await this.user.start(); return super.start(); } // ... rest of the service implementation } ~~~ > **NOTE.** We use a **dynamic** client here to demonstrate building one at > runtime. The finished Auth service in the sandbox actually ships a > **statically generated** client for the User service (committed at > `src/clients/User.ts`) — the approach we cover in > [chapter 5](https://imqueue.org/tutorial/api-service#building-the-clients). Both are valid; the > static one is the better default for real applications. With the client in place, calling the remote service is straightforward. Here's how `verify()` might look: ~~~typescript import jwt from 'jsonwebtoken'; // the signing secret comes from the environment — never hard-code it: const JWT_KEY = process.env['JWT_KEY'] || ''; // ... /** * Verifies whether a token is valid and, if so, returns the associated user * * @param {string} token - the user's auth token * @return {Promise} - the associated user, or null */ @profile() @expose() public async verify(token: string): Promise { let jwtData: any; try { jwtData = jwt.verify(token, JWT_KEY); } catch (err) { return null; } return this.user.fetch(jwtData.email); } // ... ~~~ Try implementing the rest of the Auth service's methods as homework, or take a look at [its source code on GitHub](https://github.com/imqueue-sandbox/auth). Next up: [Domain Services](https://imqueue.org/tutorial/other-services). --- # Domain services: PostgreSQL & in-memory data Source: https://imqueue.org/tutorial/other-services/ Published: 2018-11-08 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Add the remaining domain services — Car and Time-Table — an in-memory car catalog and a PostgreSQL-backed reservation time-table. **Every `@imqueue` service is built the same way regardless of what backs it — an in-memory cache, PostgreSQL, or a remote dataset — because the class and its `@expose()`d methods are the whole contract.** This chapter specifies the tutorial's last two services: `Car`, an in-memory catalog parsed from the EPA fuel-economy dataset, and `TimeTable`, PostgreSQL reservations through [`@imqueue/pg-sequelize`](https://imqueue.org/api/pg-sequelize/latest/). To make the application fully functional we need those two services. Building them isn't much different from the `User` and `Auth` services we've already covered, so we suggest tackling them as homework. If you'd rather read the finished code, both are on GitHub — [Car](https://github.com/imqueue-sandbox/car) and [Time-Table](https://github.com/imqueue-sandbox/time-table). Here are the requirements. ## Car service requirements - Source its car data from the EPA fuel-economy bulk dataset ([`vehicles.csv.zip`](https://www.fueleconomy.gov/feg/epadata/vehicles.csv.zip)) — download it, unzip it and parse the CSV. - Cache the parsed data in an in-memory store, refreshed from the remote dataset every 24 hours. - Expose a list of car objects (`CarObject`) with the following fields: * unique car identifier (`id: string`) * manufacturer name (`make: string`) * model name (`model: string`) * years of manufacture (`years: number[]`) * type (`type: string`) — one of `'mini'`, `'midsize'` or `'large'`, derived from the EPA vehicle class Here is the interface the service is expected to implement: ~~~typescript /** * Returns the list of car manufacturers (brands) * * @return {string[]} - the list of known brands */ public brands(): string[]; /** * Returns the car object for a given identifier, or a list of car objects if an * array of identifiers is given. * * @param {string | string[]} id - car identifier(s) * @param {string[]} [selectedFields] - fields to return * @return {Partial | Partial[] | null} - the found object(s), or null */ public fetch( id: string | string[], selectedFields?: string[], ): Partial | Partial[] | null; /** * Returns the list of known cars for a given brand * * @param {string} brand - car manufacturer (brand) name * @param {string[]} [selectedFields] - fields to return * @param {string} [sort] - field to sort by, defaults to 'model' * @param {'asc' | 'desc'} [dir] - sort direction, defaults to 'asc' (ascending) * @return {Partial[]} - the list of matching cars */ public list( brand: string, selectedFields?: string[], sort: string = 'model', dir: 'asc' | 'desc' = 'asc', ): Partial[]; ~~~ For implementation details, refer to the [source code](https://github.com/imqueue-sandbox/car). **Something to think about:** synchronising the in-memory data across multiple running instances of the service. The reference implementation solves this with a Redis `SET … NX` lock, so only one worker per host downloads and refreshes the dataset — see `CarsDB` in the [source](https://github.com/imqueue-sandbox/car). ## Time-Table service requirements This is the central service. Use a relational database as its data store — the reference implementation uses PostgreSQL, reached through [`@imqueue/pg-sequelize`](https://imqueue.org/api/pg-sequelize/latest/), the framework's Sequelize toolkit. Scaffold the service with that package already wired in: ~~~bash imq service create time-table ./time-table --packages sequelize ~~~ The catalog id is `sequelize`, kept as it was when the package was renamed so that existing configs keep working; what it installs is `@imqueue/pg-sequelize`, and with it Sequelize, `sequelize-typescript` and the `pg` driver. See [Package Catalog](https://imqueue.org/cli/package-catalog/) for the rest of the list. Here is the interface expected for this service: ~~~typescript /** * Returns the list of reservations for a given date (or for the current date * if omitted) * * @param {string} [date] - date to select reservations for; defaults to the current date * @param {string[]} [fields] - fields to select for each reservation * @return {Promise} - the matching reservations */ public async list(date?: string, fields?: string[]): Promise; /** * Fetches a single reservation by its identifier * * @param {string} id - identifier of the reservation to fetch * @param {string[]} [fields] - fields to select for the reservation * @return {Promise | null>} - the reservation, or null if not found */ public async fetch(id: string, fields?: string[]): Promise | null>; /** * Makes a reservation, or throws if it cannot be made * * @param {Reservation} reservation - the reservation data * @param {string[]} [fields] - fields to select for the updated reservations list * @return {Promise} - the updated reservations list */ public async reserve(reservation: Reservation, fields?: string[]): Promise; /** * Cancels a reservation * * @param {string} id - reservation identifier * @param {string[]} [fields] - fields to select for the updated reservations list * @return {Promise} - the updated reservations list */ public async cancel(id: string, fields?: string[]): Promise; /** * Returns the time-table configuration settings * * @return {Promise} - the time-table options */ public async config(): Promise; ~~~ It also exposes these complex types: `Reservation`: - `id` — reservation record identifier - `carId` — user's car identifier - `userId` — user identifier - `type` — the washing type for this reservation, one of `'fast' | 'std' | 'full'` - `duration` — a range of start and end times `TimeTableOptions`: - `start` — the station's opening time, in `HH:MM` format - `end` — the station's closing time, in `HH:MM` format - `boxes` — the number of parallel washing boxes (`number`) - `baseTime` — the duration options per washing type, as a list of: ~~~typescript { key: 'fast' | 'std' | 'full', // or whatever else... title: string, // human-readable title for the washing type duration: number, // in minutes } ~~~ ### The data layer `@imqueue/pg-sequelize` re-exports everything `sequelize` and `sequelize-typescript` export, so `Table`, `Column`, `DataType` and the rest arrive from the same place as the package's own additions. That is what lets the table and the wire format be a single declaration: put `@classType()` and `@property()` from `@imqueue/rpc` on the same class that carries the Sequelize decorators, and one file describes both. ~~~typescript import { AllowNull, AutoIncrement, BaseModel, Column, ColumnIndex, DataType, IndexMethod, PrimaryKey, Table, } from '@imqueue/pg-sequelize'; import { classType, property } from '@imqueue/rpc'; @classType() @Table({ tableName: 'Reservation', freezeTableName: true, timestamps: true, paranoid: true, }) export class Reservation extends BaseModel { @property('number') @AutoIncrement @PrimaryKey @Column(DataType.BIGINT) public id: number; // GiST is the method that makes range containment — the query every read // runs — use an index instead of a scan @property('[string, string]') @ColumnIndex({ name: 'reservation_duration', method: IndexMethod.GIST, safe: true, }) @AllowNull(false) @Column(DataType.RANGE(DataType.DATE)) public duration: [Date, Date]; } ~~~ Three things about the reference implementation are worth knowing before you write your own: - **The connection is a singleton the package owns.** [`database(dbConfig)`](https://imqueue.org/api/pg-sequelize/latest/pg-sequelize.database/) builds it on the first call and hands back the same instance on every later one, ignoring its argument — so those options are start-up configuration, not something to vary per call. - **Models are discovered, not listed.** [`modelsPath`](https://imqueue.org/api/pg-sequelize/latest/pg-sequelize.imqormoptions.modelspath/) points at the *compiled* output, and each file under it must export a symbol named after itself — `Reservation.js` exporting `Reservation`. That is why the model lives in a directory of its own with nothing else in it: an `index.js` sitting alongside would hand Sequelize `undefined`. - **Indices are declared on the columns.** `orm.sync()` creates the tables and then every index declared with `@ColumnIndex`. What that cannot express stays an explicit statement in the schema bootstrap — here the double-booking guard, whose key is one column plus two expressions. The `fields?: string[]` parameter every read method above takes is not plumbing you write by hand. [`query.autoQuery()`](https://imqueue.org/api/pg-sequelize/latest/pg-sequelize.query.autoquery/) turns it into the `SELECT` list, intersecting it with the model's real columns and falling back to the primary key — so a gateway can pass its GraphQL selection set straight through, and a name that is not a column never reaches the SQL: ~~~typescript return await Reservation.findAll(query.autoQuery( Reservation, fields, { where: { duration: { [Op.contained]: [today(dateObj), tomorrow(dateObj)], }, }, }, )); ~~~ `Op` is the one import that still comes from `sequelize` itself: its operators are ES symbols, which is also why the package offers a JSON-friendly [`FilterInput`](https://imqueue.org/api/pg-sequelize/latest/pg-sequelize.filterinput/) for filters that arrive over the wire. **Something to think about:** storing the time-table options as configurable database records rather than as defaults in code. Either way, the [complete source code is on GitHub](https://github.com/imqueue-sandbox/time-table). Next up: [API Service — integration](https://imqueue.org/tutorial/api-service). --- # GraphQL API gateway for microservices Source: https://imqueue.org/tutorial/api-service/ Published: 2018-11-08 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Put a GraphQL API in front of your services — @imqueue works beautifully with GraphQL. **An `@imqueue` fleet has no public front door of its own, so one service takes that role: a GraphQL gateway that speaks HTTP outward and typed RPC inward.** This chapter builds the tutorial's `API` service — scaffolded with `imq service create` like any other, then given `graphql-yoga` over Express — and resolves its GraphQL fields by calling the `User`, `Auth`, `Car` and `TimeTable` services through their generated clients. The API service exposes an external, HTTP-based interface and orchestrates access to the back-end services we've already built. GraphQL is an ideal fit for this role — it acts as an orchestrator over the underlying services — which is why we've chosen it here. This tutorial won't teach GraphQL itself; we'll focus on the @imqueue integration. If you're new to GraphQL, learn it from the [official](https://graphql.org/learn/) [resources](https://www.graphql.com/tutorials/) first, or skip this part and refer to the [finished source code](https://github.com/imqueue-sandbox/api) of the API service we built for you on GitHub. ## Initializing the service Although the API service differs in structure from a typical @imqueue service, we can still use @imqueue/cli to scaffold it: ~~~bash cd ~/my-tutorial-app imq service create api ./api ~~~ This installs everything needed to work with @imqueue. Since we want a GraphQL server over HTTP rather than a classic @imqueue service, we'll add a few more dependencies: ~~~bash npm i --save express cors graphql graphql-yoga graphql-relay \ graphql-fields-list compression helmet reflect-metadata npm i --save-dev @types/express @types/cors @types/compression @types/node ~~~ > **NOTE.** The exact technology stack here isn't important. You could use Apollo > Server instead, or any other solution you prefer. We've picked graphql-yoga > over Express with a hand-selected set of add-ons, but this is in no way > mandatory. Now remove `./api/src/Api.ts` (we don't need it). The root `./api/index.ts` becomes a thin bootstrap that runs the `Application` class, and `./api/src/` holds the GraphQL application. We keep all the npm scripts from the @imqueue boilerplate — they work fine for us — but we change what the service does: instead of a classic service, it now starts an HTTP server (Express) with a graphql-yoga endpoint that serves GraphQL requests. We won't walk through that setup in detail — implement it yourself if you're comfortable, or refer to the [source code](https://github.com/imqueue-sandbox/api) on GitHub. Take a closer look at [`index.ts`](https://github.com/imqueue-sandbox/api/blob/master/index.ts) and [`src/Application.ts`](https://github.com/imqueue-sandbox/api/blob/master/src/Application.ts). The heart of the @imqueue integration lives in the `bootstrapContext()` method of the `Application` class. There we instantiate all the @imqueue/rpc clients used to orchestrate requests to the underlying services, and start them as part of the API service's start-up. The started clients are then spread into the per-request context that graphql-yoga builds — together with the user resolved from the `X-Auth-User` header — and GraphQL passes that context down to every resolver in the schema, so any resolver can reach our services whenever it needs to. ~~~typescript import { clientOptions } from '../config.js'; import { user, auth, car, timeTable } from './clients/index.js'; export class Application { // ... private static context: { user: user.UserClient; auth: auth.AuthClient; car: car.CarClient; timeTable: timeTable.TimeTableClient; }; /** * Instantiates and starts the @imqueue/rpc clients used to orchestrate * requests to the back-end services */ private static async bootstrapContext(): Promise { Application.context = { user: new user.UserClient(clientOptions), auth: new auth.AuthClient(clientOptions), car: new car.CarClient(clientOptions), timeTable: new timeTable.TimeTableClient(clientOptions), }; await Application.context.user.start(); await Application.context.auth.start(); await Application.context.car.start(); await Application.context.timeTable.start(); } // ... } ~~~ As we saw in [chapter 3](https://imqueue.org/tutorial/auth-service#the-service-client), clients are a core part of @imqueue/rpc — they provide the RPC mechanism for calling remote services. There we used a dynamically built client; here we'll build client code statically. ## Building the clients Generating static client code is a good way to work with @imqueue services, because it offers several concrete advantages: - You don't have to worry about service start-up order — you don't need a service running to build its client. - You get pre-built code that your IDE can read, so you can explore each service's interface during development, with auto-completion. - You can version your client code and manage compatibility between versions. There's one more benefit: with all clients kept in a single place, there's no duplicated client code to maintain. (In larger systems the same client might be generated and used at several network locations, which then requires managing client updates — but our case is simple, so we'll leave that problem aside.) Generating client code takes a single command per service. Before running it, make sure the target service is up and running: ~~~bash imq client generate User ./api/src/clients imq client generate Auth ./api/src/clients imq client generate Car ./api/src/clients imq client generate TimeTable ./api/src/clients ~~~ You may want to wrap these in an npm script so you can regenerate all clients with a single command, for example: ~~~bash npm run rebuild-clients ~~~ ## Querying the services Finally, when building the GraphQL schema, we're ready to query our services. For example, to fetch the list of car brands: ~~~typescript import { GraphQLResolveInfo, GraphQLList, GraphQLSchema, GraphQLObjectType, GraphQLString, } from 'graphql'; import { user, car, timeTable, auth } from '../clients/index.js'; interface Context { user: user.UserClient; car: car.CarClient; timeTable: timeTable.TimeTableClient; auth: auth.AuthClient; } export const schema = new GraphQLSchema({ query: new GraphQLObjectType({ name: 'Query', fields: { brands: { description: 'Fetches the list of car brands', type: new GraphQLList(GraphQLString), async resolve( source: any, args: any, context: Context, info: GraphQLResolveInfo, ): Promise { try { return await context.car.brands(); } catch (err) { console.warn('Fetch brands error:', err); return []; } }, }, }, }), }); ~~~ The sample above keeps everything in one file to stay readable. In the finished service the schema is assembled in [`src/schema.ts`](https://github.com/imqueue-sandbox/api/blob/master/src/schema.ts) from the field definitions under `src/queries/` and `src/mutations/`, the resolver bodies live in `src/helpers/resolvers.ts`, and the `Context` type in `src/types/Context.ts`. Because the clients are started during the API service's start-up and GraphQL exposes them to every resolver through the context, we can call remote services and fetch whatever data we need, right where we need it. As the example shows, there's very little to do on the client side — just build and use. All the real implementation lives in one place: the service itself. This lets you write "normal" code, treating your services as ordinary objects with methods, and compose complex combinations of service calls imperatively inside your GraphQL resolvers. The full API service implementation is available [here](https://github.com/imqueue-sandbox/api). Next up: [Deployment](https://imqueue.org/tutorial/deployment). --- # Deploy microservices with Docker & autoscaling Source: https://imqueue.org/tutorial/deployment/ Published: 2018-11-08 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) Ship your services: per-service Docker images, environment-based configuration, and horizontal scaling for any load. This chapter covers the various aspects of deploying @imqueue-based services. There are many ways to deploy, depending on your needs. The most immediate is development deployment; another is shipping production packages. The deployment scheme also varies by environment — you might want to use every physical core of a single server, or spread many small containerised images across a network. @imqueue-based services are ready to meet any of these needs, but it's up to your developers and DevOps to decide how. The @imqueue/cli default template also provides some ready-to-use deployment functionality out of the box. **For services to scale in any direction, they must either be stateless or provide a mechanism to synchronise state across processes and network instances. Keep this in mind throughout implementation.** What does that mean in practice? Imagine a service that keeps some state in memory — say, the list of authenticated users: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; class MyService extends IMQService { private usersList: any[] = []; @expose() public addUser(data: any) { this.usersList.push(data); } } ~~~ Each time a remote client calls `addUser()`, the service mutates its in-memory state. With a single instance running, this works fine. But launch several copies and you're in trouble: after a few `addUser()` calls, each copy holds a different internal state — and the divergence is unpredictable. That's undesirable, so you must either implement a mechanism to share state between copies, or store and mutate the state in an external tool such as a database. Rolling your own state-sharing is usually non-trivial and prone to side-effects, so unless you're confident in how it works, we recommend designing your services to be stateless. Stateless services behave predictably across every deployment scenario. For example, this tutorial suggests implementing the [Car service](https://github.com/imqueue-sandbox/car) with an in-memory car database, which is stateful by design. But that database is largely static data: we refresh it roughly once every 24 hours, all running copies do so at about the same time, and we otherwise only read from it. There are minor side-effects, but they're insignificant for this system. It's neither good nor bad — you just need to understand what you're doing and what the consequences are. ## Scaling options Services are designed to run in multi-process environments. Since JavaScript on Node.js is single-threaded by nature, one process uses the power of only one core. On a multi-core machine you'll usually want to use all available cores, which you can do with a couple of configuration options. These are service options (they live on `IMQServiceOptions`), and can be set in the service's `config.ts`: ~~~typescript export const serviceOptions: Partial = { multiProcess: true, // default: false — turned off childrenPerCore: 2, // default: 1 }; ~~~ Set `multiProcess: true` to enable multi-process mode (or manage the value through environment variables). By default this forks one worker per available core. In real-world runs you may find that using all available capacity requires more than one process per core: increasing `childrenPerCore` adds more context switching per core but can still yield an overall performance gain. Treat it as a tuning knob — experiment to find the value that works best for your workload. If your deployment is based on small single-core containers, you probably don't need to touch the multi-process options at all. Either way, the right settings come from testing and experimentation. ## Building containers The @imqueue/cli default template gives each service a `Dockerfile` and a set of Docker npm scripts, so you can build and run an image for any service locally: ~~~bash npm run docker:build npm run docker:run npm run docker:stop npm run docker:ssh ~~~ Local builds require, of course, a Docker engine installed on your machine. Continuous integration runs on GitHub Actions. Every service created by @imqueue/cli ships a `.github/workflows/build.yml` that, on each push and pull request, installs dependencies and runs the test suite across the current LTS and latest Node.js versions — verifying the build stays green. (Which CI provider is wired in depends on how you configured the tool; GitHub Actions is the default, with CircleCI and Travis also available.) Building and publishing images to a container registry is left to your own deployment pipeline — run `npm run docker:build` there and push the resulting image. Pre-built Docker images can then be pulled and deployed across many cloud environments — AWS, Azure, Google Cloud Platform and others. From there it's a matter of configuring your cloud environment: enabling auto-scaling and anything else you need. One important note about running @imqueue clients in Docker containers: unless you name your clients explicitly, each client generates a unique name based on the operating system's UUID. Since Docker images share the same OS UUID out of the box, you should set a unique value on the first image build — usually in `/etc/machine-id` or `/var/lib/dbus/machine-id`. Consult the documentation for your container's base OS to find the correct location. ## Environment variables Environment variables are a powerful way to separate configuration across environments without maintaining multiple config codebases. On cloud platforms such as AWS you might use Parameter Store to supply configuration, while for local development you can use `.env` files. This requires some setup in the service's `config.ts`. Configure each option to read from an environment variable first (which you define yourself) and fall back to a default value. We covered this in [chapter 2](https://imqueue.org/tutorial/user-service#configuring-the-service). We strongly recommend following the same approach for any configuration in your real-world services, and documenting the expected environment variables in your README files — so that anyone deploying to a new environment can tune their setup easily. ## Running it locally We've covered the many options available when deploying @imqueue services. As you can see, it's a flexible solution, able to satisfy any load and suitable for horizontal scaling and cloud deployments. For this tutorial we'll focus on the default development environment, so you can run the example services from our [codebase](https://github.com/imqueue-sandbox) and experiment with them. First, clone all the repositories locally. Let's assume a dedicated directory, for example `~/imqueue-sandbox`: ~~~bash mkdir ~/imqueue-sandbox cd ~/imqueue-sandbox git clone git@github.com:imqueue-sandbox/api.git git clone git@github.com:imqueue-sandbox/auth.git git clone git@github.com:imqueue-sandbox/car.git git clone git@github.com:imqueue-sandbox/time-table.git git clone git@github.com:imqueue-sandbox/user.git git clone git@github.com:imqueue-sandbox/web-app.git ~~~ Next, make sure Redis, MongoDB and PostgreSQL are running on your development machine. Out of the box the services look for them at the standard local addresses — `localhost:6379`, `localhost:27017` and `localhost:5432`. You'll also need a PostgreSQL database named `tutmq`, owned by a user `tutmq` with the password `tutmq` — or point the time-table service at a different database through its `DB_CONN_STR` environment variable. The schema itself is created on start-up, so there's nothing to migrate by hand. If you want to watch the statements it issues, start it with `SQL_LOG=1`; `SQL_PRETTIFY=1` and `SQL_COLORIZE=1` make them readable. The auth service signs its JWTs with a secret you supply and refuses to start without one. `.env` files are git-ignored, so a fresh clone has none — generate your own secret before starting the fleet: ~~~bash cd ~/imqueue-sandbox/auth echo "JWT_KEY=$(openssl rand -hex 32)" > .env ~~~ Then install dependencies and run each service in its own terminal window (or use a multiplexer such as `screen` or `tmux` if you prefer): ~~~bash cd ~/imqueue-sandbox/[service_dir] npm i npm run dev ~~~ where `[service_dir]` is one of `user`, `auth`, `car`, `time-table` or `api`. The services talk to each other over the message queue and ship pre-generated static clients, so you can start them in any order — a service simply queues its calls until the peer it needs becomes available. Once the API service is running, the GraphiQL web interface is available at [http://localhost:8888/](http://localhost:8888/). Finally, install and start the React-based web interface: ~~~bash cd ~/imqueue-sandbox/web-app npm i npm start ~~~ `npm start` first runs the Relay compiler over the TypeScript sources — regenerating the typed query artifacts in the `__generated__` directories — and then starts the Vite dev server. You can now use the application at [http://localhost:3000/](http://localhost:3000/). ## Bonus: the same fleet, a different API The tutorial proper ends here. But if you'd like proof that nothing about the fleet ties you to GraphQL, carry on to the bonus chapters: we rebuild the API gateway as a [REST/OpenAPI service](https://imqueue.org/tutorial/rest-api/) and add [a second front-end](https://imqueue.org/tutorial/rest-web-app/) that speaks REST natively — both stacks running side by side, without changing a single line in the back-end services. Next up: [Bonus: REST API](https://imqueue.org/tutorial/rest-api). --- # Bonus: REST & OpenAPI gateway with Swagger UI Source: https://imqueue.org/tutorial/rest-api/ Published: 2026-07-23 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Swap the GraphQL gateway for a REST/OpenAPI one — same fleet, same typed clients, a different front door. **An `@imqueue` gateway's outward protocol is independent of the fleet behind it, because the fleet is reached through typed RPC clients rather than HTTP.** This bonus chapter rebuilds the tutorial's gateway as a **REST/OpenAPI** service with Swagger UI, reusing the same clients and changing nothing in the back-end services. In [chapter 5](https://imqueue.org/tutorial/api-service) we put a GraphQL endpoint in front of our services and noted, in passing, that the exact technology stack of the API layer isn't important. This bonus chapter proves that claim: we rebuild the same gateway as a **REST/OpenAPI** service — without touching a single line of the back-end fleet. In the [next bonus chapter](https://imqueue.org/tutorial/rest-web-app) we'll build a front-end that speaks to it, too. The finished service is on GitHub: [api-rest](https://github.com/imqueue-sandbox/api-rest). ## One fleet, any front door Recall the architecture: the services communicate over a Redis message queue using @imqueue RPC, and the API service is just another consumer of that fleet — it holds a set of typed clients and orchestrates calls to them. What the gateway speaks to the outside world is entirely its own business. GraphQL, REST, gRPC, WebSockets — the fleet neither knows nor cares. So the plan is simple: keep the orchestration, replace the transport. ## What stays the same Everything that touches @imqueue, literally. The four services — user, auth, car and time-table — run unmodified. The gateway uses the same statically generated clients (built with `imq client generate`, exactly as in [chapter 5](https://imqueue.org/tutorial/api-service#building-the-clients)), and its start-up context is the same code you've already seen: ~~~typescript private static async bootstrapContext(): Promise { Application.context = { user: new user.UserClient(clientOptions), auth: new auth.AuthClient(clientOptions), car: new car.CarClient(clientOptions), timeTable: new timeTable.TimeTableClient(clientOptions), }; await Application.context.user.start(); await Application.context.auth.start(); await Application.context.car.start(); await Application.context.timeTable.start(); } ~~~ This is the point of the whole exercise: the @imqueue integration is a handful of client instantiations, so it survives an API-style change untouched. ## What changes The transport layer. Instead of graphql-yoga serving a schema, the gateway is a plain Express 5 application: `helmet`, `cors`, `compression` and a JSON body parser, followed by an authentication middleware, a REST router and a terminal error handler. Where GraphQL gave us an introspectable schema and the GraphiQL playground, REST has its own well-established equivalents: the gateway builds an **OpenAPI 3 document** describing every route and schema (see [`src/openapi.ts`](https://github.com/imqueue-sandbox/api-rest/blob/main/src/openapi.ts)), serves it at `/openapi.json`, and renders an interactive **Swagger UI** at the service root — the REST analogue of GraphiQL. The resulting surface looks like this: | Method | Path | Description | |---|---|---| | POST | `/auth/login` | Log in, returns `{ token, user }` | | POST | `/auth/logout` | Invalidate a token | | GET | `/users/me` | Current user (with cars) | | GET | `/users` | List users (non-admins capped at 100) | | GET | `/users/{idOrEmail}` | Get a user by id or email | | POST | `/users` | Register (create) a user | | PATCH | `/users/{id}` | Update the authenticated user | | POST | `/users/{idOrEmail}/cars` | Attach a car (`me` for self) | | DELETE | `/users/{idOrEmail}/cars/{carId}` | Remove a car | | GET | `/brands` | Car manufacturer names | | GET | `/cars?brand=` | Catalog cars for a brand | | GET | `/cars/{id}` | Catalog car by id | | GET | `/options` | Time-table options | | GET | `/reservations?date=` | Reservations for a date | | GET | `/reservations/{id}` | Reservation by id | | POST | `/reservations` | Make a reservation | | DELETE | `/reservations/{id}` | Cancel a reservation | ## From resolvers to an orchestrator In the GraphQL gateway the orchestration logic lived inside resolvers and mutations. Here it is collected into a single [`Orchestrator`](https://github.com/imqueue-sandbox/api-rest/blob/main/src/orchestrator.ts) class of transport-agnostic methods that talk to the fleet and return plain DTOs, while the [routes](https://github.com/imqueue-sandbox/api-rest/blob/main/src/routes.ts) stay one-liners: ~~~typescript router.get( '/reservations', asyncHandler(async (req, res) => { const date = typeof req.query.date === 'string' ? req.query.date : undefined; res.json({ reservations: await Orchestrator.reservations(ctx(res), date), }); }), ); ~~~ And the orchestrator method behind it is the same @imqueue client call you would have written in a resolver: ~~~typescript public static async reservations( context: Context, date?: string, ): Promise { const list = await context.timeTable.list( date ? new Date(date).toISOString() : undefined, [...RESERVATION_FIELDS], ); return Promise.all( (list as any[]).map(r => Orchestrator.toReservationDTO(context, r)), ); } ~~~ One interesting difference hides in those field lists. In GraphQL, each query declared exactly which fields it wanted, and the gateway forwarded that selection to the services. REST has no per-request field selection, so the gateway pins a fixed field set per DTO (`USER_FIELDS`, `CAR_FIELDS`, `RESERVATION_FIELDS`) — and still passes it down through the @imqueue clients, so the services keep returning only what the gateway actually needs. ## Authentication The GraphQL gateway resolved the authenticated user from the `X-Auth-User` header; the REST gateway keeps the exact same contract, moved into an Express middleware that verifies the JWT via the auth service and attaches a per-request context: ~~~typescript export function authMiddleware(base: Context) { return async (req: Request, res: Response, next: NextFunction) => { const token = (req.header('x-auth-user') || '').trim(); const authUser = await resolveAuthUser(base.auth, token); res.locals.context = { ...base, authUser } as Context; next(); }; } ~~~ The authorization rules — active user, owner-or-admin, admin-only flags — are ported one-to-one from the GraphQL validators into small assertion helpers the orchestrator calls explicitly. ## Errors Failures are emitted in a GraphQL-compatible envelope: ~~~json { "errors": [ { "message": "Unauthorized", "extensions": { "code": "AUTH_ERROR" } } ] } ~~~ …with a proper HTTP status on top (401 for auth failures, 404 for missing resources, 409 for a duplicate e-mail, 400 otherwise). Keeping the error shape identical across both gateways is a deliberate choice: the same error-mapping routine works against either one, so a client switching gateways has nothing to relearn — which pays off in the next chapter. ## Running it With the fleet running as described in [chapter 6](https://imqueue.org/tutorial/deployment), add the REST gateway alongside: ~~~bash cd ~/imqueue-sandbox git clone git@github.com:imqueue-sandbox/api-rest.git cd api-rest npm i npm run dev ~~~ The gateway listens on `API_REST_PORT` (default **8080**) and connects to the fleet over the Redis queue configured by `IMQ_REDIS`. Open [http://localhost:8080/](http://localhost:8080/) for the Swagger UI and try the endpoints interactively — log in via `POST /auth/login`, then paste the returned token into the `X-Auth-User` authorization field. Note that nothing stops you from running **both** gateways at once: GraphQL on port 8888 and REST on port 8080, orchestrating the same services over the same queue, at the same time. That's the adaptability we set out to demonstrate. Next up: [Bonus: REST Web App](https://imqueue.org/tutorial/rest-web-app). --- # Bonus: React front-end on a REST gateway Source: https://imqueue.org/tutorial/rest-web-app/ Published: 2026-07-23 Updated: 2026-07-29 Author: @imqueue maintainers (https://github.com/imqueue) A second front-end, native to REST — no Relay, no shims, no compromises. Same fleet, same features, a client that is idiomatic for the protocol it speaks. In the [previous chapter](https://imqueue.org/tutorial/rest-api) we put a REST/OpenAPI gateway in front of the fleet. One piece is missing for a complete alternative stack: a front-end that speaks REST. The tutorial's [web-app](https://github.com/imqueue-sandbox/web-app) is built on React/Relay/GraphQL — so alongside it lives a REST edition: [web-app-rest](https://github.com/imqueue-sandbox/web-app-rest). It is worth being precise about what "edition" means here, because the obvious approach is a trap. You *can* keep the Relay components untouched and alias `react-relay` to a compatibility shim that fakes fragment containers over REST responses. That works — and it proves the wrong thing. It shows that a REST gateway can be made to imitate Relay's data-fetching model, when the claim we actually want to demonstrate is that the fleet does not care which model you bring. So `web-app-rest` carries no Relay at all: no `react-relay`, no `relay-runtime`, no `graphql` tags, no alias in the Vite config. It has its own store model, built from `fetch` and React hooks, and its components are typed against the REST DTOs the gateway actually returns. Both apps are React 19 + TypeScript with function components throughout; they share a look and a feature set, not a data layer and not a type model. ## The client At the bottom sits a small `fetch` wrapper ([`src/store/client.ts`](https://github.com/imqueue-sandbox/web-app-rest/blob/main/src/store/client.ts)): it prefixes the gateway URL, attaches the auth token from the local store as the `X-Auth-User` header — the same header contract both gateways share — and parses JSON responses: ~~~typescript export const client = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body ?? {}), patch: (path: string, body?: unknown) => request('PATCH', path, body ?? {}), del: (path: string) => request('DELETE', path), }; ~~~ On failure it rejects with a `RestError` carrying the error list from the gateway's envelope (`[{ message, extensions: { code } }]`). Remember how the REST gateway deliberately kept that error shape? This is where it pays off: both front-ends map gateway error codes onto form fields with the same small routine, because the payload they receive is the same. ## Queries are hooks There is no `QueryRenderer` and no data-loading HOC — just hooks, one per thing the app reads ([`src/store/queries.ts`](https://github.com/imqueue-sandbox/web-app-rest/blob/main/src/store/queries.ts)): ~~~typescript export function useAppRoot(vars: AppRootVars): QueryState export function useCarBrands(): QueryState export function useCarModels(brand?: string): QueryState export function useReservations(date?: Date): QueryState ~~~ Each returns `{ data, loading, error, reload }`, so a component says what it needs and renders the three states plainly: ~~~typescript const { data, loading, error } = useAppRoot(vars); ~~~ Where the GraphQL app selects several root fields in one round-trip, the REST hook issues the equivalent requests in parallel and merges them. The `vars` are the same flags the GraphQL query used, so a route still asks for exactly the data it displays: ~~~typescript const load = useMemo>( () => async () => { const tasks: Promise[] = []; const result: AppRootData = {}; if (withUser || withUserCars) { tasks.push(client.get('/users/me').then(user => { result.user = user; })); } if (withOptions) { tasks.push(client.get('/options').then(options => { result.options = options; })); } if (withReservations) { tasks.push(client .get<{ reservations: Reservation[] }>('/reservations') .then(res => { result.reservations = res.reservations; })); } await Promise.all(tasks); return result; }, [withUser, withUserCars, withOptions, withReservations], ); return useQuery(load, true); ~~~ All four hooks are built on one internal `useQuery` engine, which is where the unglamorous but essential parts live: a request whose inputs have since changed is discarded rather than allowed to overwrite newer data, and a hook with nothing to fetch — no brand picked yet, no date selected — issues no request at all instead of asking the gateway for nothing. ## Mutations are hooks too Each operation is a module exporting a hook that returns the commit function and an in-flight flag — the flag being what disables the submit button while the request is out: ~~~typescript export function useReserve(): [ (reservation: ReserveInput, options?: ReserveOptions) => void, boolean, ] { const [isInFlight, setInFlight] = useInFlight(); const reserve = useCallback(async ( { carId, type, duration }: ReserveInput, { success, failure }: ReserveOptions = {}, ) => { setInFlight(true); try { const payload = await client.post('/reservations', { carId, type, duration: duration.map(item => item.toISOString()), }); success && success(payload); } catch (err) { logger.error('reserveMutation:request', err); failure && failure(toErrorList(err)); } finally { setInFlight(false); } }, [setInFlight]); return [reserve, isInFlight]; } ~~~ ## Reactivity without a normalized store One thing Relay gives you for free is store reactivity: when a mutation returns updated records, everything reading them re-renders. A `fetch`-based app has no normalized cache, so this one earns the same effect with an invalidation bus ([`src/store/bus.ts`](https://github.com/imqueue-sandbox/web-app-rest/blob/main/src/store/bus.ts)) — about twenty lines around a `Set` of listeners: ~~~typescript export function onDataChange(handler: DataChangeHandler): Unsubscribe export function emitDataChange(): void ~~~ The mutations that change the current user (`updateUser`, `addCar`, `removeCar`) announce it, and the hooks marked *live* reload **in the background** — no loading flag raised, the last good data left on screen until the new data arrives. The list just updates, exactly as it did with Relay. The catalog hooks are not live, because car makes and models do not change under the user. This is the honest trade to look at closely. Relay's store buys you automatic, fine-grained invalidation and costs you a compiler, generated artifacts and a fragment discipline. The bus buys you a re-fetch of whole queries for a handful of lines and no build step. Which is the better deal depends entirely on the app — and that judgement is yours to make per client, which is the whole point of keeping it out of the services. ## Running it With the fleet and the REST gateway from the [previous chapter](https://imqueue.org/tutorial/rest-api) running: ~~~bash cd ~/imqueue-sandbox git clone git@github.com:imqueue-sandbox/web-app-rest.git cd web-app-rest npm i npm start ~~~ The dev server listens on port **3001** — deliberately, so it can run alongside the GraphQL web-app on port 3000. It points at the REST gateway on `http://localhost:8080/` by default (override with `VITE_WEB_API_URL`). If you've followed every chapter, you now have two complete stacks running side by side — GraphQL/Relay at [http://localhost:3000/](http://localhost:3000/) and REST/OpenAPI at [http://localhost:3001/](http://localhost:3001/) — with the same features, orchestrating the **same four @imqueue services** over the same message queue. Each app's title bar carries a protocol badge, `GraphQL` or `REST`, so you always know which one you are looking at. Try it: register a customer in one, then log into the other. Add a car on :3000 and watch it appear in the garage on :3001. Book a washing slot over REST and cancel it over GraphQL. There is one fleet behind both, and it never learns which protocol asked. ## The takeaway Nothing in an @imqueue fleet ties you to any particular API technology. The services expose typed, transport-agnostic RPC over the queue; whatever sits in front of them — GraphQL, REST, or anything else you might need tomorrow — is a thin, replaceable orchestration shell. And because that shell is thin, each client is free to be *good* at the protocol it speaks rather than pretending to speak another one. Happy hacking! --- # CLI User Guide Source: https://imqueue.org/cli/ Published: 2026-07-21 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) Everything about the imq command — from installation to writing your own templates and adapting the tool to real-world projects. `@imqueue/cli` (the `imq` command) is a Rapid Application Development tool for the [@imqueue](https://imqueue.org/) framework — a Redis-backed RPC microservice toolkit for Node.js/TypeScript. It scaffolds services from templates, wires them to your VCS host, CI provider and container registry, generates strongly-typed RPC clients, and helps you run and maintain a whole fleet of services locally. This manual covers everything from installation to writing your own templates and adapting the tool to real-world projects. > **New in 4.x** — the old standalone shell tools `imqctl`, `imqlog` and > `imqup` are now native subcommands: `imq ctl`, `imq log`, `imq up`. > Service creation is built on a **four-axis provider model** (VCS host, CI, > registry, addon packages) that is fully backward compatible with 3.x > configs. See [Configuration](https://imqueue.org/cli/configuration/) and [Providers](https://imqueue.org/cli/providers/). ## The command surface at a glance | Command | What it does | |---|---| | `imq service create` | Scaffold a new service from a template; optionally create the remote repo, provision CI secrets, commit, push and tag. | | `imq service update-version` | Bump the version of one or many services on a branch and push, triggering CI. | | `imq client generate` | Generate a typed RPC client from a running service. | | `imq config` | `init` / `get` / `set` / `check` the CLI configuration. | | `imq completions` | Install/remove shell completions (bash & zsh). | | `imq ctl` | Start / stop / restart a bulk of local services. | | `imq log` | Tail and combine local service logs. | | `imq up` | Bulk-update service dependencies (and optionally version/commit/push). | ## Where to start 1. **[Installation](https://imqueue.org/cli/installation/)** — install the CLI and shell completions. 2. **[Configuration](https://imqueue.org/cli/configuration/)** — run `imq config init` to set your defaults (VCS host, CI, registry, namespaces, tokens). 3. **[Creating Services](https://imqueue.org/cli/creating-services/)** — scaffold your first service. 4. **[Managing Local Services](https://imqueue.org/cli/managing-local-services/)** — run many services at once during development. ## Design principles - **Non-interactive by default when it can be.** Every option resolves with a strict precedence (flag → per-service `.imqrc.json` → global config → interactive prompt → default), so CI and scripted runs never hang. - **Backward compatible.** A config written by 3.x keeps working; the new structured keys and the legacy keys are kept in sync. - **Data-driven where possible.** The addon package catalog and the templates live in a separate repo, so they can evolve without a CLI release. - **Testable and portable.** Every network endpoint has an environment-variable override, which also enables GitHub Enterprise, self-managed GitLab and Bitbucket Cloud-compatible endpoints. ## Conventions in this manual - `~/.imq/` is the CLI home; override the base with `IMQ_CLI_HOME`. - Shell snippets assume a POSIX shell. Windows users should use WSL or Git Bash. - Angle brackets `` mark required values; square brackets `[like-this]` mark optional ones. --- # Installation Source: https://imqueue.org/cli/installation/ Published: 2026-07-21 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) Install @imqueue/cli, check requirements, upgrade from 3.x, and enable shell completions. ## Requirements - **Node.js ≥ 22.12.0** (the CLI is ESM and uses modern Node APIs). - **git** on your `PATH` (used for repo creation, commits, template fetch). - A running **Redis** if you intend to generate clients from live services or run services locally. - Optional, per feature: - An SSH key **or** nothing special — templates are fetched over public HTTPS by default. - `npm-check-updates` — installed automatically by `imq up` if missing. - Docker — only if you enable service dockerization. ## Install globally ```bash npm i -g @imqueue/cli ``` Verify: ```bash imq --version imq --help ``` Running `imq` with no arguments prints the command list. ## Upgrading ```bash npm i -g @imqueue/cli@latest ``` On every interactive run the CLI checks npm for a newer release and offers to self-update. To disable that check (e.g. in CI or slow networks): ```bash export IMQ_NO_UPDATE_CHECK=1 ``` ### Upgrading from 3.x In 4.0 the three standalone shell tools were folded into the `imq` binary. Update any scripts or aliases: | 3.x | 4.x and later | |---|---| | `imqctl start …` | `imq ctl start …` | | `imqlog …` | `imq log …` | | `imqup …` | `imq up …` | All options are unchanged. Your existing `~/.imq/config.json` continues to work untouched — see [Configuration](https://imqueue.org/cli/configuration/#backward-compatibility). ## Shell completions The CLI can install completion scripts for **bash** and **zsh**: ```bash imq completions on # append the completion block to ~/.bashrc or ~/.zshrc imq completions off # remove it ``` Then reload your shell or `source ~/.bashrc` (or `~/.zshrc`). zsh is detected from `ZSH_VERSION` or a `$SHELL` ending in `zsh` — not from any `ZSH*` variable that happens to be set, so the `ZSH`/`ZSH_THEME` pair that oh-my-zsh exports into bash subshells no longer misidentifies the shell. ## Files the CLI creates | Path | Purpose | |---|---| | `~/.imq/config.json` | Global configuration (written `0600` — may hold secrets). | | `~/.imq/templates/` | Cached clone of the templates repo. | | `~/.imq/custom-templates/` | Named custom templates you add. | | `~/.imq/var/` | Runtime state for `imq ctl`/`imq log`: `*.log` and `.pids`. | Override the base directory (useful for sandboxing or CI) with: ```bash export IMQ_CLI_HOME=/some/where # ~/.imq becomes /some/where/.imq ``` ## Uninstall ```bash npm r -g @imqueue/cli rm -rf ~/.imq # optional: remove cached templates, config, logs ``` --- # Configuration Source: https://imqueue.org/cli/configuration/ Published: 2026-07-21 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) @imqueue/cli (the imq command) resolves every option through four layers — a command-line flag, the project's .imqrc.json, the global config, then a prompt. This page covers that precedence, the structured v4 schema, secrets, git transport, and every IMQ_* environment variable. The CLI reads configuration from three layers, merged with a strict precedence so that scripted runs are deterministic: ``` CLI flag → per-service .imqrc.json → global ~/.imq/config.json → interactive prompt (TTY only) → built-in default ``` A prompt is only shown when the process is attached to a TTY and no earlier layer supplied a value; otherwise the default is used, or — for a required value with no default (author, email, and a VCS namespace/token on a real create) — the command fails fast with a clear error instead of hanging. This is what lets `imq service create … --dry-run` and CI pipelines run without blocking on input. ## Quick start ```bash imq config init ``` An interactive wizard walks you through the four axes and stores your answers globally. Do this once after installation to make later commands short. When you enable a VCS host, the wizard **auto-detects the git transport**: it defaults to `ssh` if you have SSH keys in `~/.ssh` (overridable with `IMQ_SSH_DIR`) and `https` otherwise, reports which it picked and why, and lets you change it — see [Git transport](#git-transport-for-the-initial-push-https-vs-ssh). ## Managing config values ```bash imq config get # print every set option as "key = value" imq config get --json # print the whole config as JSON (-j for short) imq config get ci.provider # print a single value imq config set ci.provider circleci imq config set vcs.namespace my-org imq config set packages opentelemetry,pg-cache # comma list OR a JSON array imq config set vcs.provider giturb # rejected: prints the valid list imq config check # exit 0 if initialized, 1 otherwise (for scripts) ``` `get`/`set` accept **dot-paths** into the structured config (e.g. `registry.region`, `vcs.auth.token`). The file is written with `0600` permissions because it may hold secrets (VCS token, registry password). Setting a structured key (`vcs.*`, `ci.*`, `registry.*`, `packages`, `templatesRef`) also updates the mapped legacy keys, so a config written by v4 still works if the CLI is downgraded to v3. ## The structured (v4) schema `~/.imq/config.json` holds a structured view built from these groups: ### `vcs` — version control host | Key | Meaning | |---|---| | `vcs.provider` | `github` \| `gitlab` \| `bitbucket` | | `vcs.namespace` | user / organization / workspace that owns new repos | | `vcs.private` | create repositories as private (`true`/`false`) | | `vcs.protocol` | git transport for the create-time push: `https` (default) \| `ssh` | | `vcs.auth.token` | API/personal-access token for repo creation & secrets | ### `ci` — continuous integration | Key | Meaning | |---|---| | `ci.provider` | `github-actions` \| `circleci` \| `travis` | | `ci.auth.token` | token used to enable the repo / set CI secrets (CircleCI, Travis) | ### `registry` — container registry | Key | Meaning | |---|---| | `registry.provider` | `dockerhub` \| `google` \| `aws-ecr` \| `azure-acr` | | `registry.namespace` | image namespace / repository / ACR name | | `registry.region` | region (Google Artifact Registry, AWS ECR) | | `registry.project` | GCP project id (Google) | | `registry.accountId` | AWS account id (ECR) | | `registry.auth.user` / `registry.auth.password` | registry credentials (DockerHub) | ### Top-level | Key | Meaning | |---|---| | `packages` | default addon packages added to new services (array) | | `templatesRef` | git ref of the templates repo to use (default `master`) | Example `~/.imq/config.json`: ```json { "vcs": { "provider": "github", "namespace": "my-org", "private": true }, "ci": { "provider": "github-actions" }, "registry": { "provider": "google", "project": "my-gcp-proj", "region": "europe-west1" }, "packages": ["opentelemetry", "pg-cache"], "templatesRef": "master" } ``` ## Per-service overrides: `.imqrc.json` When a service is created, its resolved providers and packages are written to a committed `.imqrc.json` at the service root. Later commands (and re-creations) read it, so a service always rebuilds with the tools it was born with — even if your global defaults have since changed. A `.imqrc.json` value overrides the global config but is still overridden by an explicit CLI flag. ```json { "vcs": { "provider": "gitlab", "namespace": "team-x" }, "ci": { "provider": "circleci" }, "packages": ["sequelize", "tag-cache"] } ``` ## Secrets and tokens Tokens can be provided by (in order of preference): 1. A CLI flag for one-off use: `-T, --github-token ` (used for any VCS host, not only GitHub). 2. The config: `vcs.auth.token`, `ci.auth.token`, `registry.auth.password`. 3. An interactive prompt. Because the config file may contain these, it is always written `0600`. Prefer per-invocation flags or environment injection in shared CI environments. ## Git transport for the initial push (HTTPS vs SSH) When `imq service create` commits and pushes the new repository, it uses one of two transports, selected by `vcs.protocol` (or the `--git-protocol` flag): | `vcs.protocol` | Push behavior | |---|---| | `https` (**default**) | Push over `https://…` **authenticated with the access token** that created the repo. The token is used **only for that push** (via an ephemeral `http.extraHeader`) and is never written into the repository's `.git/config`, which keeps a clean, token-free remote URL. | | `ssh` | Push over the host's `git@…:…` SSH URL using **your own SSH keys/agent**. No token is injected — you need working SSH access to the namespace. | Precedence is the usual one: `--git-protocol` flag → `.imqrc.json` → global config → the `https` default. **Why HTTPS is the default.** The access token that just created the repo is guaranteed to have write access, so the push succeeds even for a private organization repo where your SSH key — or a *different* "active" git/gh account — has no access (the classic misleading `Repository not found` on push). Choose `ssh` when you specifically rely on SSH keys (e.g. org policy, hardware-key signing, or an SSH-only host): ```bash imq config set vcs.protocol ssh # make ssh the default for new services imq service create my-svc ./my-svc --git-protocol https # or override per run ``` > `IMQ_GIT_REMOTE_BASE` still overrides the push target entirely (custom / > self-hosted git or integration testing) and takes precedence over > `vcs.protocol`; no token is injected in that mode. ## Backward compatibility A configuration written by 3.x uses legacy keys (`gitBaseUrl`, `gitHubAuthToken`, `gitRepoPrivate`, `useGit`, `useDocker`, `dockerHubNamespace`, `dockerHubUser`, `dockerHubPassword`). The CLI: - **reads** them transparently and derives an equivalent structured view (github + travis + dockerhub, namespace parsed from `gitBaseUrl`); - **writes** both the structured keys *and* their legacy equivalents, so a config remains usable if you downgrade the CLI. You do not need to migrate anything by hand. ## Environment variable reference | Variable | Effect | |---|---| | `IMQ_CLI_HOME` | Base for `~/.imq` (sandboxing / CI). | | `IMQ_NO_UPDATE_CHECK` | Skip the npm self-update check. | | `IMQ_TEMPLATES_REPO` | Override the templates git URL (fork or SSH). | | `IMQ_GITHUB_API_URL` | GitHub API base — set to a GitHub Enterprise host. | | `IMQ_GITLAB_API_URL` | GitLab API base — self-managed GitLab. | | `IMQ_BITBUCKET_API_URL` | Bitbucket API base — a Bitbucket Cloud 2.0-compatible endpoint. | | `IMQ_CIRCLECI_API_URL` | CircleCI API base. | | `IMQ_TRAVIS_API_URL` | Travis API base. | | `IMQ_GIT_REMOTE_BASE` | Base for the git remote used on commit/push (testing seam). | | `IMQ_SSH_DIR` | SSH directory inspected for keys when auto-detecting the git transport (defaults to `~/.ssh`). | | `CIRCLE_TOKEN` | CircleCI token fallback (used when `ci.auth.token` is unset). | These are also the seams used by the test harness; in production they enable enterprise / self-hosted deployments without any code change. See [Providers](https://imqueue.org/cli/providers/#enterprise--self-hosted) and [Extensibility](https://imqueue.org/cli/extensibility/). Every variable above is read by **`imq` itself**. A service the CLI generates reads its own, separate set at runtime — `SERVICE_NAME`, the `IMQ_*` queue settings in its `src/config.ts`, and whatever the addons you selected require. For the tracing ones see [Package Catalog](https://imqueue.org/cli/package-catalog/#the-name-a-traced-service-reports). ### Cloud-registry credentials (read at create time) When a service is dockerized against a cloud registry, `imq service create` reads the following from the invoking environment and provisions them as CI secrets on the new repository. If a variable is unset, **no secret is provisioned** for it — the CLI reports which secrets were and weren't set, and the CI's `docker login` will fail until you add them manually. | Registry | Environment variables | |---|---| | `google` (Artifact Registry) | `GCP_SA_KEY` (service-account JSON key) | | `aws-ecr` | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY` | | `azure-acr` | `AZURE_CLIENT_ID`, `AZURE_CLIENT_SECRET` | Docker Hub credentials come from `registry.auth.user`/`registry.auth.password` (config) or an interactive prompt instead. --- # Creating Services Source: https://imqueue.org/cli/creating-services/ Published: 2026-07-21 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) imq service create — scaffold a service from a template and, optionally, create the repo, provision CI secrets, commit, push and tag it. `imq service create` scaffolds a new @imqueue service from a template and, optionally, creates the remote repository, provisions CI secrets, commits, pushes and tags it. ```bash imq service create [path] ``` If omitted, `name` defaults to the current directory's name and `path` to `.` (the current directory). ## The four axes Service creation is organized around four independent, pluggable axes, so you can mix the tools you actually use. Each resolves via the standard precedence (flag → `.imqrc.json` → global config → fleet → prompt → default). | Axis | Flag | Choices (fallback **bold**) | |---|---|---| | VCS host | `--vcs` | **github**, gitlab, bitbucket | | CI provider | `--ci` | **github-actions**, circleci, travis | | Container registry | `--registry` | **dockerhub**, google, aws-ecr, azure-acr | | Addon packages | `--packages` | (none) — see [Package Catalog](https://imqueue.org/cli/package-catalog/) | The bold value is the fallback, not a fixed default. With nothing configured, `imq service create` reads the git remotes and CI config of the services that already sit alongside the new one and proposes what they use — a service joining services hosted on GitLab is going on GitLab. The bold value applies when there is no fleet to learn from, or when the fleet does not agree with itself. [Package Catalog](https://imqueue.org/cli/package-catalog/#following-the-fleet) describes the analysis and its cache. A VCS host and a CI provider get picked either way — unlike an addon, choosing none is not an option — so this applies to non-interactive runs too. If you script `imq service create` inside a fleet and want a specific host or provider regardless of its neighbours, pass `--vcs` / `--ci` explicitly. CI choices are filtered to those compatible with the selected VCS host. See [Providers](https://imqueue.org/cli/providers/) for the details and tokens each one needs. ## Options ``` imq service create [name] [path] -a, --author Author full name (person or organization) -e, --email Author contact email -g, --use-git Turn on automatic repo creation [boolean] --vcs VCS host: github | gitlab | bitbucket -u, --github-namespace VCS namespace (user, organization, workspace) --ci CI provider: github-actions | circleci | travis --registry Registry: dockerhub | google | aws-ecr | azure-acr --region Registry region (google, aws-ecr) --project GCP project id (google) --account-id AWS account id (aws-ecr) --packages Comma-separated addon packages (--no-packages = none) --no-install Do not run npm install after scaffolding [boolean] -V, --service-version Initial version [default: "1.0.0-0"] -H, --homepage Homepage URL -B, --bugs-url Bug tracker URL -l, --license SPDX id or path to a custom license file -t, --template Template name, git url, or local directory -d, --description Service description -n, --node-versions Node version tags for CI (comma-separated) -D, --dockerize Enable dockerization in CI builds [boolean] -L, --node-docker-tag Base node docker tag -N, --docker-namespace Registry namespace / repository / ACR name -T, --github-token VCS auth token (any host, not only GitHub) -p, --private Create the repository private [boolean] --dry-run Print the resolved plan and exit [boolean] -y, --yes Skip the confirmation prompt [boolean] ``` ## Preview with `--dry-run` Always safe, makes no changes. Prints the fully-resolved plan — providers, repo URL, image reference, packages — exactly as it would execute: ```bash imq service create billing ./billing \ --vcs gitlab --ci circleci --registry google \ --project my-proj --region europe-west1 \ --packages opentelemetry,pg-cache --dry-run -a "My Org" -e dev@my-org.io ``` A dry run makes no network calls, so it does **not** require a VCS namespace or auth token — those are shown as `` in the plan. It does still validate the always-required inputs (author and email). Use it in scripts and CI to preview a given set of flags before committing to a real run. ## What a run does (pipeline) When repo creation is enabled (`-g`/`--use-git` or a configured VCS), a full run performs, in order: 1. **Resolve** the plan from all config layers. 2. **Scaffold** the service from the template (token substitution + addon overlays); generate `src/.ts` and its test; merge addon dependencies. 3. **Write `.imqrc.json`** with the resolved choices (no secrets). 4. **Create** the remote repository on the VCS host. 5. **Provision CI** — enable the repo and set secrets (e.g. GitHub Actions sealed secrets, CircleCI env vars, Travis RSA-encrypted vars). 6. Compile the **CI/docker** tokens. 7. **Install** dependencies (`npm install`) unless `--no-install`. 8. **Initialize git** locally (sets a local commit identity from the author/ email so it works even without a global git identity), **commit**, add the **remote**, **push**, and **tag** the initial version. By default the push is sent over **HTTPS and authenticated with the same access token** that created the repo (injected only for that push, never written into the repository's git config). This is what makes a push to a private org repo succeed even when your SSH key — or a different "active" git/gh account — has no access to it. The remote left in `.git/config` is the clean, token-free HTTPS URL. To push over **SSH** with your own keys instead, pass `--git-protocol ssh` (or set `vcs.protocol: ssh`) — see [Configuration → Git transport](https://imqueue.org/cli/configuration/#git-transport-for-the-initial-push-https-vs-ssh). 9. **Report** any addon instructions and environment variables you must set. Without repo creation, the remote/CI-secret/commit/push steps (4, 5, 8) are skipped; scaffold, `.imqrc.json`, CI/docker token compilation and install still run. ### Failure & rollback - The target directory is only removed on failure if the CLI **created** it — a pre-existing directory (or the current/home directory) is never deleted. A non-empty target is refused up front unless you pass `--force`. - If the remote repository was already created when a later step fails, you are asked (interactively) whether to **delete** it (full roll back) or **keep** it and fix the problem manually — the prompt spells out both outcomes and **defaults to keeping** it. Non-interactively it is always left in place with a notice, so nothing is destroyed silently. - Enabling CI and provisioning secrets are **non-fatal**: on failure the CLI prints what to do manually and continues. It reports which registry secrets were actually provisioned rather than assuming success. > The generated service targets ESM + TypeScript + the native `node:test` > runner, matching the current default template. Run `npm test` inside it out > of the box. ## Non-interactive / CI usage Provide everything via flags (or config) and add `-y` to skip confirmation. A real (non-dry-run) create with a VCS host needs an auth token — pass `-T`/ `--vcs-token` (or set `vcs.auth.token` in config): ```bash imq service create orders ./orders -y \ -a "My Org" -e dev@my-org.io -l MIT \ --vcs github -u my-org -T "$GITHUB_TOKEN" --ci github-actions \ --registry dockerhub -N myorg --no-install ``` ## Templates `--template` accepts a **name** (a bundled or custom template), a **git URL**, or a **local directory**. With no flag the default template is used, fetched over public HTTPS and pinned to the `templatesRef` from your config (default `v4`). See [Custom Templates](https://imqueue.org/cli/custom-templates/) to build your own. ## The generated `.imqrc.json` The resolved providers and packages are committed to `.imqrc.json` in the new service so later commands and re-creations reuse them. Edit it to change a single service's tools without touching your global defaults — see [Configuration](https://imqueue.org/cli/configuration/#per-service-overrides-imqrcjson). --- # Package Catalog Source: https://imqueue.org/cli/package-catalog/ Published: 2026-07-21 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) Add secondary @imqueue libraries to a new service with --packages, wired in automatically from a data-driven catalog. `imq service create --packages ` adds secondary @imqueue libraries to a new service and wires them in automatically. The catalog is **data** (`catalog.json`, shipped with the CLI and mirrored in the templates repo), so new addons can appear without a CLI release. ```bash imq service create billing ./billing --packages opentelemetry,pg-cache,tag-cache imq service create billing ./billing --no-packages # explicitly none ``` You can also set a default list globally so every new service gets them: ```bash imq config set packages opentelemetry,pg-cache ``` To see every available package id (grouped, with a one-line description): ```bash imq service packages # human-readable imq service packages --json # machine-readable ``` ## Groups Packages belong to groups. **Exclusive** groups accept at most one member; selecting two members of the same exclusive group is rejected with an error. At most one, not exactly one — selecting none is a normal answer, and for a service that talks to no database it is the right one. | Group | Exclusive? | Members | |---|---|---| | **Tracing / APM** | yes | `opentelemetry`, `dd-trace` | | **ORM / database** | yes | `pg-prisma`, `sequelize` | | **Service features** | no | `pg-cache`, `pg-pubsub`, `tag-cache`, `job`, `net`, `http-protect`, `graphql-dependency`, `type-graphql-dependency`, `validation`, `core`, `gcp` | Three feature entries are worth a word, since they are not capabilities in the same sense as the rest: - `validation` — `@imqueue/validation` plus `zod`, for `@validatable` / `@validate` argument classes and `@validated` methods. - `core` — adds `@imqueue/core` as a **direct** dependency. It arrives transitively through `@imqueue/rpc` anyway; take this only if you import from it directly. - `gcp` — the Google Cloud Trace exporter. It needs `opentelemetry` selected as well, and exports traces once `GOOGLE_APPLICATION_CREDENTIALS` is set. These are catalog **ids** — what `--packages` takes and what a saved config holds — not npm package names, and two of them no longer match. `dd-trace` installs `@imqueue/datadog` and `sequelize` installs `@imqueue/pg-sequelize`, both renamed while the ids stayed put so that existing configs and `.imqrc.json` files keep working. ## What each addon does when selected For every selected package the scaffolder: 1. **Merges its dependencies** (and devDependencies) into the service `package.json`, preserving the versions declared by the template/catalog. 2. **Injects wiring code** at the template's addon token points: - `%ADDON_PRELOAD` — imports / setup that must run early (e.g. tracing bootstrap before other imports). - `%ADDON_CONFIG` — configuration wiring inside the service setup. 3. May add **extra files** the addon needs. 4. **Prints required environment variables** after creation (e.g. tracing endpoints, database URLs), so you know exactly what to configure. Those printed variables are the ones an addon *needs* (`OTEL_EXPORTER_OTLP_ENDPOINT`, `DD_AGENT_HOST`, `DATABASE_URL`, …). Beyond them, the generated code reads a few of its own — see below. ### The name a traced service reports Both tracing addons write their bootstrap into a module of their own (`src/telemetry.ts` for `opentelemetry`, `src/tracer.ts` for `dd-trace`), which the preload token imports before anything else. Where OpenTelemetry gets the `service.name` on its spans from depends on the template's contract version: | [Template](https://imqueue.org/cli/custom-templates/#template-versions-v1-vs-v2) | Variable | How it resolves | |---|---|---| | **v2** (the shipped default) | `SERVICE_NAME` | Read through `src/config.ts` as `config.serviceName`. It is zod-validated and **defaults to the service name you scaffolded with**, so it only needs setting to report something else. | | **v1** (a template with no `imq-template.json`) | `IMQ_SERVICE_NAME` | The CLI inlines `const serviceName = process.env.IMQ_SERVICE_NAME \|\| ''` straight into the generated `src/telemetry.ts`. | Check which one applies by looking at the file: a v2 service's `telemetry.ts` imports `config`, a v1 service's declares `serviceName` at the top. Neither variable is printed after creation, because neither has to be set. `dd-trace` is not in that table on purpose — its module takes no service name at all. Datadog resolves its own, so use `DD_SERVICE` (or the rest of `dd-trace`'s configuration) there, exactly as you would outside @imqueue. Do not confuse either variable with the `%SERVICE_NAME` **template token**, which is substituted once, at scaffold time — see [Custom Templates](https://imqueue.org/cli/custom-templates/#token-substitution). ## Choosing addons interactively Run `imq config init` or `imq service create` on a TTY without `--packages` and you will get a multi-select for the feature group and single-selects for the exclusive groups. Non-interactive runs use your config/flags and never prompt. Each exclusive list marks one member **(recommended)**, and `(none)` is always the first choice. The recommendation is `pg-prisma` for the ORM and `opentelemetry` for tracing — unless the fleet says otherwise. ### Following the fleet `imq service create` looks at the directory the new service is being created into, and treats every sibling directory whose `package.json` depends on `@imqueue/rpc` as part of your fleet. If those services already agree on an ORM or a tracing backend, that member becomes both the preselected and the recommended one, with a line above the list saying why: ``` ? Select ORM / database: Only if the service uses a database — none is normal. Preselected sequelize to match 2 services in this fleet. Moving the fleet to pg-prisma is worth considering — as its own piece of work, not as part of this. (none) Prisma ORM + @imqueue/pg-prisma toolkit ❯ Sequelize ORM + @imqueue/pg-sequelize toolkit (recommended) ``` A new service in an established fleet belongs on the fleet's stack: matching what is already there beats taking the default. A fleet that disagrees with itself gets no proposal: with a strict majority the majority wins, and on a tie nothing is preselected and only the fallback is marked. The same analysis drives the VCS host and CI provider prompts — see [Creating Services](https://imqueue.org/cli/creating-services/). Scanning is cheap but not free, so the result is cached in `~/.imq/var/fleet.json`, keyed by directory (`IMQ_CLI_HOME` relocates it with the rest of the CLI's files). The cache is invalidated when the set of sibling directories changes. Choosing against the analysis is taken as intent: your choice is recorded as an override for that directory and proposed next time, until a later scan agrees with it on its own. ## Extending the catalog Because the catalog is data, you can publish new addons by editing `catalog.json` in your own fork of the templates repo (point the CLI at it via `IMQ_TEMPLATES_REPO` and `templatesRef`). Each entry declares its group, dependencies, the snippets to inject at the addon token points, any extra files, and the environment variables to advertise. See [Custom Templates](https://imqueue.org/cli/custom-templates/) and [Extensibility](https://imqueue.org/cli/extensibility/). --- # Providers Source: https://imqueue.org/cli/providers/ Published: 2026-07-21 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) @imqueue/cli scaffolds a service along four independent axes — template, VCS host, CI provider and container registry — each selected by a flag. This page lists every provider on those axes, the credentials each one needs, and how the CI and registry choices compose. Service creation composes four axes through a typed provider registry. This page details each provider, the credentials it needs, and how CI and registry providers combine. ## VCS hosts (`--vcs`) | Provider | Namespace means | Token | API base override | |---|---|---|---| | **github** (default) | user or organization | Personal Access Token with `repo` scope (and `admin:org` for org repos / Actions secrets) | `IMQ_GITHUB_API_URL` | | **gitlab** | user or group | Personal Access Token with `api` scope | `IMQ_GITLAB_API_URL` | | **bitbucket** | workspace | Bitbucket **Cloud** access token (repo admin), sent as a Bearer token | `IMQ_BITBUCKET_API_URL` | The VCS provider is responsible for **creating the remote repository** and, where applicable, storing CI secrets. The SCM tool (git) is deliberately split from the host, so the same git commit/push flow serves all three (and other SCMs could be added later). Provide the token with `-T/--github-token` (works for any host) or via `vcs.auth.token` in the config. The initial commit/push transport is set by `vcs.protocol` (`--git-protocol`): `https` (default) authenticates the push with the access token — injected only for that push, never persisted to `.git/config` — while `ssh` pushes over the host's SSH URL with your own keys. Each host declares the basic-auth username its token uses for HTTPS (`x-access-token` for GitHub, `oauth2` for GitLab, `x-token-auth` for Bitbucket). See [Configuration → Git transport](https://imqueue.org/cli/configuration/#git-transport-for-the-initial-push-https-vs-ssh). ## CI providers (`--ci`) | Provider | Secret mechanism | Token | API base override | |---|---|---|---| | **github-actions** (default) | libsodium **sealed-box** secrets via the GitHub API | GitHub token (same as VCS) | `IMQ_GITHUB_API_URL` | | **circleci** | project environment variables via the CircleCI API | CircleCI token | `IMQ_CIRCLECI_API_URL` | | **travis** (legacy) | RSA (PKCS1) **secure** variables | Travis token | `IMQ_TRAVIS_API_URL` | CI choices are filtered to those compatible with the chosen VCS host (e.g. GitHub Actions requires GitHub). `travis` is kept working for existing setups but is not recommended for new services. ## Container registries (`--registry`) | Provider | Extra inputs | Notes | |---|---|---| | **dockerhub** (default) | `registry.auth.user` / `password`, `-N` namespace | classic Docker Hub | | **google** | `--project`, `--region` | **Artifact Registry** (not the retired GCR) | | **aws-ecr** | `--account-id`, `--region` | Amazon ECR | | **azure-acr** | `-N` ACR name | Azure Container Registry | ## How CI and registry compose Rather than hand-writing an M×N matrix of "CI provider × registry" build scripts, the CLI composes them through a small set of **generic shell-snippet tokens** that the CI template fills from the registry provider: - `%REGISTRY_LOGIN` — the login command(s) for the chosen registry - `%REGISTRY_PUSH` — the push command(s) - `%IMAGE_REF` — the fully-qualified image reference - `%DOCKER_NAMESPACE` / `%DOCKER_SECRETS` — namespace and required secret names - `%GHA_NODE_MATRIX` / `%GHA_SECRETS_ENV` / `%TRAVIS_NODE_TAG` — CI-specific rendering of node versions and secrets This keeps the number of moving parts at **M + N** instead of **M × N**: add a registry and every CI provider can push to it; add a CI provider and it can push to every registry. ## Enterprise / self-hosted Every provider's API base URL is overridable, which turns the built-in providers into enterprise-ready ones with no code change: ```bash # GitHub Enterprise Server export IMQ_GITHUB_API_URL=https://github.mycorp.com/api/v3 # self-managed GitLab export IMQ_GITLAB_API_URL=https://gitlab.mycorp.com/api/v4 # an API-compatible Bitbucket endpoint / proxy export IMQ_BITBUCKET_API_URL=https://bitbucket.mycorp.com/api/2.0 ``` > The override relocates the API **base URL** only; it does not translate > between API dialects. The GitHub and GitLab providers speak the same API > shape as their enterprise/self-managed servers, so those work directly. The > Bitbucket provider speaks the **Bitbucket Cloud 2.0** API; point the override > at a Cloud-2.0-compatible endpoint (Bitbucket Server/Data Center's 1.0 API is > a different dialect and is not supported as-is). Combine with `IMQ_GIT_REMOTE_BASE` if your git remote host differs from the API host. These same variables are how the test suite exercises the providers against mock servers — see [Extensibility](https://imqueue.org/cli/extensibility/). ## Tokens: where they come from For any provider token the resolution order is: 1. `-T/--github-token` flag (one-off; applies to the active VCS host) 2. config: `vcs.auth.token`, `ci.auth.token`, `registry.auth.password` 3. for CircleCI, the `CIRCLE_TOKEN` environment variable (fallback for `ci.auth.token`) 4. interactive prompt (TTY only) A legacy `gitHubAuthToken` from a v3 config is only reused for the **github** host, never for gitlab/bitbucket. Because the config may store these, `~/.imq/config.json` is always written `0600`. In shared CI, prefer passing tokens per-invocation or via environment injection rather than persisting them. --- # Clients & Versioning Source: https://imqueue.org/cli/clients-and-versioning/ Published: 2026-07-21 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) Generate strongly-typed RPC clients from running services, and bump versions across many services to trigger CI. ## Generating typed RPC clients `imq client generate` produces a strongly-typed RPC client from a **running** service — the service must be up (and Redis reachable) so its interface can be introspected; otherwise generation fails. ```bash imq client generate [path] ``` | Flag | Meaning | |---|---| | (positional) `name` | service name to generate a client for (required) | | (positional) `path` | directory to place the client file (default: cwd) | | `-o, --overwrite` | overwrite an existing client without prompting | | `-w, --timeout` | seconds to wait for the service to respond before giving up (default `30`; `0` waits forever) | ```bash # from within a project, service "billing" running locally imq client generate billing ./src/clients -o ``` Typical flow during development: ```bash imq ctl start -s billing -c # bring the service up and wait for readiness imq client generate billing ./src/clients imq ctl stop -s billing ``` ## Bumping versions across many services `imq service update-version` releases a new version across one or many services under a directory and pushes, triggering CI builds. ```bash imq service update-version [branch] ``` | Flag | Meaning | |---|---| | (positional) `path` | directory containing the services (or a single service) | | (positional) `branch` / `-b` | branch to checkout/use (default `master`) | | `-n, --npm-version`, `--bump` | bump type: `major\|minor\|patch\|prerelease` (default `prerelease`). Unlike `imq up`, `update-version` does not constrain the keyword — any value is passed through to `npm version`. | For each detected service it runs, stopping that service on the first failing step: ``` git checkout → git pull → npm version → git push --follow-tags ``` Detection here is by **loading the built module** and checking whether any export derives from `IMQService` (by walking the prototype chain — the service class need not be named `*Service`). Compare with `imq up`/`imq ctl`, which detect by scanning source. Use `update-version` for a release action against built, committed services; use `imq up` for dependency maintenance. ```bash # patch-release every service under ./services on the main branch imq service update-version ./services main -n patch ``` ## `update-version` vs `up` | | `imq service update-version` | `imq up` | |---|---|---| | Purpose | release/version bump | dependency maintenance | | Detects services by | module load (prototype chain) | source scan (`extends IMQService/IMQClient`) | | Touches deps? | no | yes (`ncu -u` + reinstall) | | Git flow | checkout → pull → version → push | (optionally) commit → version → push | | Branch control | `-b/[branch]` | uses current branch | --- # Managing Local Services Source: https://imqueue.org/cli/managing-local-services/ Published: 2026-07-21 Updated: 2026-08-01 Author: @imqueue maintainers (https://github.com/imqueue) Run a whole fleet of services side by side with imq ctl, imq log and imq up — start, stop, tail logs and bulk-update dependencies. During development it is common to run several @imqueue services on your host at once. The `imq ctl`, `imq log` and `imq up` commands manage a whole fleet of service repositories sitting side-by-side in one directory. > These replace the 3.x shell tools `imqctl`, `imqlog`, `imqup`. Options are > unchanged. ## Service discovery `imq ctl` and `imq up` share the same discovery. When `-s/--services` is **not** given, they scan the target path (`-p`, default: current directory) for immediate sub-directories whose `src/` tree contains a class extending `IMQService` or `IMQClient`. This is a **source-level** scan — it needs neither a build nor a running service, so it works on freshly-cloned, uninstalled repos. Pass `-s alpha,beta` to target specific services and skip the scan. (`imq log` does not scan a path; it works off the `*.log` files already collected under `~/.imq/var`.) Runtime state lives under `~/.imq/var/`: - `~/.imq/var/.log` — captured stdout/stderr per service (truncated each time the service is started) - `~/.imq/var/.pids` — `service:pid` records of running masters, written incrementally as each service starts ## `imq ctl` — start / stop / restart / status ```bash imq ctl [-p path] [-s services] [-u] [-c] [-v] ``` | Flag | Meaning | |---|---| | `-p, --path` | directory containing the service repos (default: cwd) | | `-s, --services` | comma-separated service names (skips discovery) | | `-u, --update` | run `git pull` in each service before starting (a failed pull skips that service) | | `-c, --calm` | wait for each service to become ready before starting the next | | `-v, --verbose` | print total execution time | **Start** launches each service via its `npm run dev` script in its own process group, redirecting output to `~/.imq/var/.log`, and records the master pid. A service that is **already running** (its recorded pid is live) is skipped with a warning — use `restart` to restart it. **Calm mode** (`-c`) polls the log for the readiness marker `reader channel connected` before moving on, so services with startup dependencies come up in order. Because the log is truncated on start, the scan only sees the current run. If a service **exits during startup** it is reported at once (rather than waiting out the bounded timeout). **Stop** terminates each targeted service's entire process group with `SIGTERM` (so child processes die too), waits for it to actually exit, and escalates to `SIGKILL` if it refuses; a process that still won't die keeps its pid entry with a warning. It then runs each service's `npm run stop` script if it has one, and prints a summary. Pids of services you did not target are preserved. If no services are discoverable from the current directory (and no `-s` was given), `stop` falls back to stopping every tracked pid — so it works from anywhere. **Restart** = stop then start; it waits for the old process to fully exit before relaunching, so the two never run concurrently. **Status** lists each tracked service and whether its recorded pid is live or stale (honoring `-s`, and pruning stale entries it reports). A **start** that finds no services exits non-zero. `-s` accepts a comma list, repeated flags (`-s a -s b`), or both. ```bash # start everything under ~/work/services, waiting for each to be ready imq ctl start -p ~/work/services -c # see what is running imq ctl status -p ~/work/services # restart just two services, pulling latest first imq ctl restart -s billing,orders -u # stop everything imq ctl stop -p ~/work/services ``` ## `imq log` — combined logs ```bash imq log [services..] [-c] [-f] [-P] ``` | Flag | Meaning | |---|---| | (positional) | service names to show (default: all available logs) | | `-c, --clean` | delete collected logs and exit (scoped to the named services, or all logs when none are named) | | `-f, --follow` | follow appended data (default **on**; `--no-follow` dumps and exits) | | `--no-prefix` | do not prefix lines with the service name (`-P` for short) | When more than one log is shown, each line is prefixed with a coloured `[service]` tag so interleaved output stays readable. `--no-follow` is handy in scripts to snapshot current logs and return immediately. ```bash imq log # tail & combine every service log imq log billing orders # only these two imq log --no-follow # dump current logs and exit imq log --clean # wipe all collected logs imq log billing --clean # wipe only billing's log ``` ## `imq up` — bulk dependency update ```bash imq up [-p path] [-s services] [-v type] [-c] [-u] ``` | Flag | Meaning | |---|---| | `-p, --path` | directory containing the service repos (default: cwd) | | `-s, --services` | comma-separated service names (skips discovery) | | `-v, --npm-version`, `--bump` | version bump on commit: `major\|minor\|patch\|prerelease` (default `prerelease`) | | `-c, --commit` | commit, version-bump and push the update | | `-u, --skip-update` | skip the dependency update, perform other tasks only | For each service the update runs `git pull` → `ncu -u` ([npm-check-updates](https://www.npmjs.com/package/npm-check-updates), installed globally on first use if missing) → remove `node_modules` + `package-lock.json` → `npm install`. With `-c` it then commits `chore: dependencies update`, runs `npm version ` and `git push --follow-tags` — but **only when the working tree actually changed** (a stray *untracked* file does not count as a change). A step that fails aborts that service **before** any destructive step, is recorded, and the run continues with the next service; the command exits non-zero and prints a summary if any service failed. `-v` only accepts the four bump keywords (anything else is rejected). `imq up --skip-update` without `--commit` is a no-op and is rejected with a helpful message. Make sure services are not in a dirty git state before an update+commit run. ```bash # update deps everywhere, no git changes imq up -p ~/work/services # update, then patch-bump, commit and push each changed service imq up -p ~/work/services -c -v patch # only re-commit/bump (no dep update) — e.g. after a manual edit imq up -s billing --skip-update --commit -v minor ``` See [Real-World Scenarios](https://imqueue.org/cli/real-world-scenarios/) for end-to-end fleet workflows. --- # Custom Templates Source: https://imqueue.org/cli/custom-templates/ Published: 2026-07-21 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Every service imq scaffolds comes from a template: the built-in default, a published one, or your own — pointed at by name, git URL or local path. This page covers selecting a template, the v2 imq-template.json manifest, %TOKEN substitution and fragment overlays. Templates are the boilerplate `imq service create` clones and compiles into a new service. You can use the built-in default, a published template, or your own — pointed at by name, git URL, or local path. ## Selecting a template `--template` (`-t`) accepts: | Form | Example | |---|---| | **name** | `-t default` (bundled or a named custom template in `~/.imq/custom-templates`) | | **git URL** | `-t https://github.com/my-org/imq-template.git` | | **local directory** | `-t ./my-template` | With no flag, the default template is fetched over public **HTTPS** and pinned to the `templatesRef` from your config (default `master`). Override the source repo entirely with `IMQ_TEMPLATES_REPO` (e.g. your fork, or an SSH URL for contributors): ```bash export IMQ_TEMPLATES_REPO=git@github.com:my-org/templates.git imq config set templatesRef main ``` ## Template versions (v1 vs v2) A template is **v2** when it contains an `imq-template.json` manifest; otherwise it is treated as legacy **v1**. New templates should be v2. ### The manifest — `imq-template.json` ```json { "version": 2, "description": "My org's @imqueue service template (ESM, TS, node:test)", "ciFiles": "provider" } ``` | Field | Meaning | |---|---| | `version` | manifest version — `2` for the current format | | `description` | human-readable description | | `ciFiles` | documents that CI files are emitted by the selected CI provider (rather than shipped in the template). This is descriptive: **any** v2 template gets provider-emitted CI files - the field is not a switch | An absent or unreadable manifest → the template is compiled as v1. ## Token substitution Every file under the template is compiled: `%TOKEN` placeholders are replaced. The base tokens available to template files: | Token | Replaced with | |---|---| | `%SERVICE_NAME` | the service name | | `%SERVICE_CLASS_NAME` | the generated service class name | | `%SERVICE_VERSION` | the initial version | | `%SERVICE_DESCRIPTION` | the service description | | `%SERVICE_AUTHOR_NAME` / `%SERVICE_AUTHOR_EMAIL` | author name / `` | | `%SERVICE_REPO` / `%SERVICE_HOMEPAGE` / `%SERVICE_BUGS` | package.json repository / homepage / bugs fragments (from the VCS host) | | `%LICENSE_HEADER` / `%LICENSE_TEXT` / `%LICENSE_NAME` / `%LICENSE_TAG` | the license header block / full text / name / SPDX tag | | `%ADDON_PRELOAD` | addon early-init snippets (empty when no addons) | | `%ADDON_CONFIG` | addon configuration snippets (empty when no addons) | A token value is inserted verbatim, so `$` characters in an author name or license text are safe. Provider/registry/CI composition tokens (filled from the chosen providers) are also available in CI/Docker files — see [Providers](https://imqueue.org/cli/providers/#how-ci-and-registry-compose): `%IMAGE_REF`, `%REGISTRY_LOGIN`, `%REGISTRY_PUSH`, `%DOCKER_NAMESPACE`, `%DOCKER_SECRETS`, `%GHA_NODE_MATRIX`, `%GHA_SECRETS_ENV`, `%TRAVIS_NODE_TAG`. Package metadata fragments (repository, homepage, bugs) are derived and injected into `package.json` from the resolved VCS host and the `-H`/`-B` flags. ## Addon token points For a template to support the [Package Catalog](https://imqueue.org/cli/package-catalog/), place the two addon anchors where addon code should land — typically: - `%ADDON_PRELOAD` near the top of the entry file (`index.ts`), before other imports, for things like a tracing bootstrap; - `%ADDON_CONFIG` inside the service setup (`config.ts`), for configuration wiring. When no addons are selected, both compile to empty strings, so a template with these anchors still produces clean output. ## Fragment overlays Beyond whole-file compilation, providers and addons can **overlay file fragments** onto the scaffolded service (writing or replacing specific files by relative path). This is how a CI provider contributes its workflow file and how an addon contributes any extra files it needs — without the base template having to know about every provider or package. ## Writing your own template 1. Start from the default template (clone the templates repo, copy `default/`). 2. Keep or add `imq-template.json` (`version: 2`). 3. Author your files with `%TOKEN` placeholders; put `%ADDON_PRELOAD` / `%ADDON_CONFIG` where addon code should go. 4. Keep CI files out of the template if you want provider-emitted CI (`"ciFiles": "provider"`). 5. Point the CLI at it: ```bash imq service create demo ./demo -t ./path/to/my-template --dry-run ``` Iterate with `--dry-run` and local scaffolding until happy. 6. Publish by hosting the template in a git repo and sharing the URL, or drop it in `~/.imq/custom-templates/` and refer to it by ``. ## Adapting to an existing project You are not limited to greenfield services. Because a template is just a directory of files with tokens, you can encode your organization's conventions — lint config, tsconfig, Dockerfile, CI, license header, base dependencies, even a house `%ADDON_CONFIG` — into a custom template so every new service is born consistent with the rest of your codebase. Combine with a default `packages` list and a configured VCS/CI/registry to make `imq service create -y` produce a fully wired, on-brand service in one command. --- # Extensibility Source: https://imqueue.org/cli/extensibility/ Published: 2026-07-21 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) @imqueue/cli is adapted to an environment without forking it: environment-variable overrides for enterprise hosts, data-driven templates and an addon catalog in JSON, and a typed provider registry contributors extend in one place. The v4 architecture is built to be extended along its four axes without rewrites. This page explains the seams for adapting the tool to your environment and, for contributors, for adding new providers. ## The provider model Service creation composes four axes through a typed **provider registry**: - **VCS host** — creates the remote repo and stores CI secrets. Split from the **SCM tool** (git), so the commit/push flow is shared and other SCMs could be added later. - **CI provider** — enables the repo and sets secrets; contributes its workflow file as a fragment overlay. - **Container registry** — supplies the login/push/image-reference snippets. - **Package catalog** — data-driven addon libraries. CI and registry combine through generic shell-snippet tokens, so the surface is **M + N**, not **M × N** — see [Providers](https://imqueue.org/cli/providers/#how-ci-and-registry-compose). ## Adapting without code: environment seams Every network endpoint is overridable, which turns the built-in providers into enterprise/self-hosted ones and makes the whole tool testable: | Variable | Use | |---|---| | `IMQ_GITHUB_API_URL` | GitHub Enterprise Server | | `IMQ_GITLAB_API_URL` | self-managed GitLab | | `IMQ_BITBUCKET_API_URL` | Bitbucket Cloud 2.0-compatible endpoint | | `IMQ_CIRCLECI_API_URL` | CircleCI (or a proxy) | | `IMQ_TRAVIS_API_URL` | Travis (or a proxy) | | `IMQ_GIT_REMOTE_BASE` | base for the git remote on commit/push | | `IMQ_TEMPLATES_REPO` + `templatesRef` | your own template source & ref | ## Adapting without code: data - **Templates** are a git repo of files with `%TOKEN` placeholders and fragment overlays — no CLI release needed to change boilerplate. See [Custom Templates](https://imqueue.org/cli/custom-templates/). - **The addon catalog** (`catalog.json`) is data: groups, dependencies, injection snippets, extra files and advertised env vars. Publish new addons by editing it in your template source. See [Package Catalog](https://imqueue.org/cli/package-catalog/#extending-the-catalog). ## For contributors: adding a provider The providers live under `src/providers/` grouped by axis (`vcs/`, `ci/`, `registry/`, `scm/`) with shared types in `src/providers/types.ts` and registration in `src/providers/index.ts` (`registerBuiltinProviders()`). To add, say, a new VCS host: 1. Implement the VCS provider interface in `src/providers/vcs/.ts` (repo creation, secret storage), reading its API base from a new `IMQ__API_URL` env override for testability/enterprise. 2. Register it in `registerBuiltinProviders()`. 3. Add it to the `--vcs` choices and any CI-compatibility filtering. 4. Add unit tests under `test/src/providers/` (mirror the existing `vcs.spec.ts` / `ci.spec.ts` style, driving the provider against a mock API via the env override). The same shape applies to CI providers (implement `enable()`/`setSecrets()`, optional) and registries (supply the `%REGISTRY_LOGIN` / `%REGISTRY_PUSH` / `%IMAGE_REF` snippets). ## For contributors: the codebase in brief | Area | Location | |---|---| | Command entry (yargs) | `index.ts`, `src/*.ts`, `src/**/**.ts` | | Shared library | `lib/*.ts` (config, resolve, template, services, github, travis, …) | | Providers | `src/providers/**` | | Service creation | `src/service/create-*.ts` (plan → scaffold → pipeline) | | Addon catalog engine | `src/catalog/**` + `lib/catalog.json` | | Tests | `test/**` (native `node:test`, module mocks) | See **AGENTS.md** in the repo root for a deeper orientation aimed at contributors and AI coding agents (build/test commands, invariants, gotchas). --- # Real-World Scenarios Source: https://imqueue.org/cli/real-world-scenarios/ Published: 2026-07-21 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) End-to-end walkthroughs that chain the imq commands together — a new service on GitHub/Actions/Docker Hub, the same on GitHub Enterprise, running a local @imqueue fleet, dependency maintenance, and a coordinated fleet-wide release. End-to-end walkthroughs that combine the commands. Each assumes you have run `imq config init` once (adjust to taste). ## 1. New service on GitHub + GitHub Actions + Docker Hub The default stack. One command, non-interactive: ```bash imq service create billing ./billing -y \ -a "Acme Inc" -e dev@acme.io -l MIT \ --vcs github -u acme --ci github-actions \ --registry dockerhub -N acme -D ``` This scaffolds the service, creates the `acme/billing` repo, provisions GitHub Actions sealed secrets, commits, pushes and tags `1.0.0-0`, and enables dockerized CI builds pushing to `acme/billing` on Docker Hub. The push goes over HTTPS authenticated with your token by default, so it works even for a private org repo your SSH key can't reach; add `--git-protocol ssh` to push with your own keys instead. ## 2. GitLab + CircleCI + Google Artifact Registry ```bash imq service create orders ./orders -y \ -a "Acme Inc" -e dev@acme.io -l MIT \ --vcs gitlab -u acme-group --ci circleci \ --registry google --project acme-prod --region europe-west1 \ --packages opentelemetry,pg-cache ``` Preview it first without touching anything: ```bash imq service create orders ./orders --dry-run \ --vcs gitlab -u acme-group --ci circleci \ --registry google --project acme-prod --region europe-west1 \ --packages opentelemetry,pg-cache -a Acme -e dev@acme.io ``` ## 3. GitHub Enterprise (self-hosted) Point the GitHub provider at your enterprise API; everything else is the same: ```bash export IMQ_GITHUB_API_URL=https://github.acme-corp.com/api/v3 imq service create payments ./payments -y \ --vcs github -u platform --ci github-actions \ -T "$GHE_TOKEN" -a "Acme Corp" -e platform@acme-corp.com ``` Analogously use `IMQ_GITLAB_API_URL` for self-managed GitLab, or `IMQ_BITBUCKET_API_URL` for a Bitbucket Cloud 2.0-compatible endpoint. See [Providers](https://imqueue.org/cli/providers/#enterprise--self-hosted). ## 4. A local fleet of services You have a folder `~/work/services` with several service repos side by side. ```bash cd ~/work/services # bring them all up, waiting for each to be ready, pulling latest first imq ctl start -u -c # watch combined, colour-prefixed logs imq log # generate a client for one of them while it runs (in another terminal) imq client generate billing ./billing/src/clients -o # restart a couple after code changes imq ctl restart -s billing,orders # stop everything when done imq ctl stop ``` ## 5. Fleet-wide dependency maintenance ```bash cd ~/work/services # update deps everywhere (rewrites package.json + reinstalls; no git commit) imq up # then patch-bump, commit and push each service that actually changed imq up -c -v patch ``` `imq up` installs `npm-check-updates` on first use if it is missing, and only commits/pushes services whose working tree changed. Make sure trees are clean before an `-c` run. ## 6. Coordinated release across services To cut a release across many services on a branch (triggering their CI): ```bash imq service update-version ~/work/services main -n minor ``` For each service it does `git checkout main → git pull → npm version minor → git push --follow-tags`, stopping a given service on the first failing step. Compare with `imq up` in [Clients & Versioning](https://imqueue.org/cli/clients-and-versioning/#update-version-vs-up). ## 7. Standardising new services for your org Encode your conventions once in a [custom template](https://imqueue.org/cli/custom-templates/) and set org defaults: ```bash export IMQ_TEMPLATES_REPO=git@github.com:acme/imq-templates.git imq config set templatesRef main imq config set vcs.provider github imq config set vcs.namespace acme imq config set ci.provider github-actions imq config set registry.provider dockerhub imq config set packages opentelemetry,pg-cache ``` Now every new service is one command and comes out fully wired and on-brand: ```bash imq service create ./ -y -a "Acme Inc" -e dev@acme.io ``` --- # Troubleshooting Source: https://imqueue.org/cli/troubleshooting/ Published: 2026-07-21 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Fixes for the @imqueue/cli failures people actually hit: imq config init hanging in CI, template fetch and SSH errors, git identity and push failures, imq ctl finding no services, and how to reset the CLI's state entirely. ## `imq config init` / prompts hang or fail in CI Prompts only appear on a TTY. In CI, provide values via flags or config and add `-y` to `service create`. If a value is missing and there is no TTY, the default is used rather than blocking. ## Template fetch fails / asks for SSH credentials The default template is fetched over public **HTTPS**, so no SSH key is required. If you overrode the source with an SSH URL via `IMQ_TEMPLATES_REPO`, either set up your SSH key or unset the variable to use the HTTPS default. To pin a different ref: `imq config set templatesRef `. ## `git commit` fails: "unable to auto-detect email address" The create pipeline sets a **local** git identity from the service author/email before committing, so this should not occur during `service create`. If you hit it in your own scripts, set a repo-local identity: ```bash git -C config user.name "Your Name" git -C config user.email "you@example.com" ``` ## Repo creation returns 401/403 Check the token and its scopes for the selected VCS host ([Providers](https://imqueue.org/cli/providers/#vcs-hosts---vcs)): GitHub needs `repo` (and `admin:org` for org repos / Actions secrets), GitLab needs `api`, Bitbucket needs a repository-scoped access token (sent as a Bearer token). Pass it with `-T` or set `vcs.auth.token`. ## Push fails with "Repository not found" (repo *was* created) The remote repository is created, but the final commit/push fails with `ERROR: Repository not found` / `Could not read from remote repository`. This is an SSH access problem: your SSH key (or the currently "active" git/gh account, if you have several) has no push access to the namespace — common with a private organization repo. By default the CLI now pushes over **HTTPS authenticated with the access token that created the repo**, which avoids this entirely. If you have explicitly selected SSH (`vcs.protocol: ssh` or `--git-protocol ssh`), either: - switch that service/config back to HTTPS — `imq config set vcs.protocol https` (or pass `--git-protocol https`); or - fix your SSH access to the namespace (add the key to the right account / add the account to the org), then re-run or push manually. See [Configuration → Git transport](https://imqueue.org/cli/configuration/#git-transport-for-the-initial-push-https-vs-ssh). ## Nothing happens on an enterprise/self-hosted host Set the matching API base URL and (if the git host differs) the remote base: ```bash export IMQ_GITHUB_API_URL=https://github.mycorp.com/api/v3 export IMQ_GIT_REMOTE_BASE=git@github.mycorp.com: ``` See [Configuration](https://imqueue.org/cli/configuration/#environment-variable-reference). ## `imq ctl` / `imq log` / `imq up` find no services Discovery scans immediate sub-directories of the path (`-p`, default cwd) for a class extending `IMQService`/`IMQClient` under `src/`. Ensure you point at the **parent** directory that contains the service repos, or pass `-s name1,name2` to target them explicitly. ## A service won't start with `imq ctl` - It must have an `npm run dev` script. Check `~/.imq/var/.log` for the actual error. - In calm mode (`-c`) the CLI waits for the log line `reader channel connected`; if your service never prints it, calm mode will warn and move on after a bounded wait — the service may still be running, just not detected as "ready". ## `imq ctl stop` didn't kill child processes Services are started in their own process group and stopped with a group `SIGTERM`. If a service double-forks outside its group, terminate it manually using the pid in `~/.imq/var/.pids`. ## `imq up` reports "Nothing to perform" `--skip-update` without `--commit` is a no-op. Either drop `--skip-update` (to update deps) or add `--commit` (to re-commit/bump without updating). ## `imq client generate` fails The target service must be **running** and Redis reachable so its interface can be introspected. Start it first (e.g. `imq ctl start -s -c`), then generate. ## Config or secrets leaked into a shared machine `~/.imq/config.json` is written `0600`. Prefer passing tokens per-invocation (`-T`) or via environment injection in shared/CI environments rather than persisting them in the config. ## Resetting everything ```bash rm -rf ~/.imq # config, cached templates, custom templates, logs, pids ``` Or sandbox a run entirely: `IMQ_CLI_HOME=/tmp/imq-sandbox imq …`. --- # MCP server for AI coding agents Source: https://imqueue.org/mcp/ Published: 2026-07-22 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Connect your AI coding agent to @imqueue: live documentation search, idiomatic service & client scaffolding, and control of your local fleet — as tools the agent calls directly. ## What this is **`@imqueue/mcp`** is a [Model Context Protocol](https://modelcontextprotocol.io) server for @imqueue. MCP is the open standard that lets AI coding agents call external tools; this server gives any MCP-capable agent — **Claude Code, Claude Desktop, Cursor, VS Code, Visual Studio, JetBrains IDEs** and others — a set of @imqueue-specific tools it can invoke while you work. Instead of your agent guessing at the API from a stale training snapshot, it **searches the current docs**, **scaffolds idiomatic code**, and — when you have the CLI installed — **drives the real `imq` binary** to create services, generate typed clients and manage your local fleet. Think of it as the code-time counterpart to search-engine ranking: @imqueue shows up as authoritative *inside the tools you already build with*. ## Install in 30 seconds Most clients take one line. **Claude Code:** ~~~bash claude mcp add imqueue -- npx -y @imqueue/mcp ~~~ Every other client takes this JSON in its MCP config (VS Code and Visual Studio use a slightly different shape — see [Add to your AI tool](https://imqueue.org/mcp/installation/)): ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ No API keys, no build step, no account. It runs straight from npm and only ever talks to `imqueue.org`. → **[Full setup for every client](https://imqueue.org/mcp/installation/)** ## Or skip the install — use the hosted endpoint Prefer to try it with zero setup? Point any HTTP-capable MCP client at the live hosted server: ~~~ https://mcp.imqueue.org/mcp ~~~ ~~~json { "mcpServers": { "imqueue": { "url": "https://mcp.imqueue.org/mcp" } } } ~~~ The hosted endpoint serves six tools over Streamable HTTP — instant, no Node, no npm, no account — and every one of them is read-only: the **documentation** and **scaffolding** tools (`search_docs`, `get_doc`, `list_packages`, `scaffold_service`, `scaffold_client`) plus `local_install_guide`, which returns the setup steps for the full install. The **CLI-bridge** tools (`create_service`, `generate_client`, `fleet`, …) act on *your* project and running services, so a hosted server cannot reach them — and does not offer them. They are **not in its tool list at all**: it advertises only what it can actually do. For those, use the **local** install above. **Rule of thumb: hosted to explore and scaffold, local to build.** ### See it answering *Video: 3:04, no audio — the hosted @imqueue server answering inside ChatGPT: one call per tool, ending with get_doc refusing an off-site URL.* — [watch](https://imqueue.org/images/mcp-demo.mp4) ## What your agent can do The server groups its tools into three capabilities: ### Live documentation Search and read these docs on demand, always current: - **`search_docs`** — find the most relevant guide, tutorial, CLI or API pages for a question. - **`get_doc`** — read any page in full as markdown, ready to quote. - **`list_packages`** — the @imqueue package catalog with one-liners and install commands. Because the docs are fetched live from imqueue.org (not bundled), the server can never go stale against a release. ### Offline scaffolding Generate idiomatic code with zero dependencies — works even without the CLI: - **`scaffold_service`** — an `IMQService` subclass with `@expose()`d, JSDoc-typed methods plus a bootstrap that starts it. - **`scaffold_client`** — the command to generate the real typed client from a running service, plus an illustrative usage snippet. ### CLI bridge When [`@imqueue/cli`](https://imqueue.org/cli/) is installed, the agent can drive the **real** `imq`: create provider-wired services, generate live-introspected clients, and manage a local fleet — `cli_status`, `cli_install`, `cli_help`, `create_service`, `generate_client`, `fleet`, `config`, `logs`. → **[Full tools reference](https://imqueue.org/mcp/tools/)** · **[Agent workflows](https://imqueue.org/mcp/workflows/)** ## Built to be trusted You are wiring an autonomous agent to a tool that can read your docs and, with the CLI, touch your filesystem. The server is designed for that: - **Local & private (the `npx` install)** — runs on your machine over stdio; nothing is sent anywhere except doc fetches to imqueue.org. - **The hosted endpoint cannot touch your machine, and does not pretend it can** — `mcp.imqueue.org` serves six read-only tools and does not register the CLI-bridge tools at all, so there is no tool on it that could reach your filesystem or your `imq` config. Each request is handled statelessly, with no sessions and nothing stored. - **Host-locked** — `get_doc` will only ever fetch `imqueue.org`. - **Safe by default** — `create_service` runs as a **dry-run** unless you explicitly opt in; read-only tools (`search_docs`, `cli_status`, `config get`, `fleet status`) are clearly separated from ones that change state. - **No telemetry, no keys** — nothing to sign up for. → **[Safety model & troubleshooting](https://imqueue.org/mcp/security/)** ## At a glance | | | |---|---| | **Package** | [`@imqueue/mcp`](https://www.npmjs.com/package/@imqueue/mcp) on npm | | **Registry ID** | `org.imqueue/mcp` (official MCP registry) | | **Transport** | stdio (local) · Streamable HTTP (hosted) | | **Hosted endpoint** | [`https://mcp.imqueue.org/mcp`](https://mcp.imqueue.org/mcp) — 6 read-only tools (docs + scaffolding) | | **Local install** | 13 tools — the 5 above plus the 8 CLI-bridge tools | | **Runtime** | Node.js ≥ 18 (local install) | | **Source** | [github.com/imqueue/mcp](https://github.com/imqueue/mcp) | | **License** | GPL-3.0 | --- # Add the MCP server to Claude, Cursor & VS Code Source: https://imqueue.org/mcp/installation/ Published: 2026-07-22 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Exact setup for Claude, Cursor, VS Code, Visual Studio, JetBrains and every other MCP client — with the config file path and paste-ready snippet for each. **Adding the `@imqueue` MCP server to any client means registering one command — `npx -y @imqueue/mcp` — under that client's `mcpServers` config key.** Only the config file's location and exact shape differ between Claude Code, Claude Desktop, Cursor, VS Code, Visual Studio, JetBrains, Windsurf and Zed; this page gives the path and a paste-ready snippet for each. ## Before you start The server needs **Node.js ≥ 18** on your `PATH`. Nothing else — it is fetched from npm on first launch and requires no API keys or account. Every client below runs the **same** command; only *where* you put the config and its exact *shape* differ. The universal building block is: ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ Jump to your tool: [Claude Code](#claude-code) · [Claude Desktop](#claude-desktop) · [Cursor](#cursor) · [VS Code](#vs-code) · [Visual Studio](#visual-studio) · [JetBrains](#jetbrains) · [Windsurf](#windsurf) · [Zed](#zed) · [Other clients](#other-clients) · [Verify & troubleshoot](#verify-it-worked) > **One rule to remember:** most clients use the `mcpServers` key above. > **VS Code and Visual Studio** are the exceptions — they use a top-level > `servers` key with an explicit `"type": "stdio"`. Copying the wrong shape into > those two is the single most common setup mistake. ## Two ways to connect: local or hosted - **Local (`npx`, recommended for building)** — the server runs on your machine over stdio and exposes **every** tool, including the CLI-bridge that creates services, generates live-introspected clients and manages your fleet. All the per-client sections below cover this. - **Hosted (zero-install, for exploring)** — point an HTTP-capable client at **`https://mcp.imqueue.org/mcp`**. No Node, no npm, no account; it serves six read-only tools — the documentation and scaffolding ones plus `local_install_guide`. The CLI-bridge tools are **not offered there**, because a hosted server cannot reach your machine. ### Connect to the hosted endpoint Clients that support remote (HTTP) MCP servers take a **`url`** instead of a `command`: ~~~json { "mcpServers": { "imqueue": { "url": "https://mcp.imqueue.org/mcp" } } } ~~~ - **Claude Code:** `claude mcp add --transport http imqueue https://mcp.imqueue.org/mcp` - **VS Code / Visual Studio:** use the `servers` shape with `"type": "http"`: ~~~json { "servers": { "imqueue": { "type": "http", "url": "https://mcp.imqueue.org/mcp" } } } ~~~ - **Cursor, Windsurf, JetBrains and others:** the `url` form above (in place of `command`/`args`). Everything below is the **local** (stdio) setup — the full-power option. ## Claude Code One command adds it for your user account: ~~~bash claude mcp add imqueue -- npx -y @imqueue/mcp ~~~ To share it with a **team**, add it at project scope so it lands in the repo — create `.mcp.json` at the project root: ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ Anyone who opens the project in Claude Code is prompted to enable it. List and check servers with `claude mcp list`. ## Claude Desktop Open the config from the app — **Settings → Developer → Edit Config** — or edit it directly: | OS | Path | |---|---| | **Linux** | `~/.config/Claude/claude_desktop_config.json` | | **macOS** | `~/Library/Application Support/Claude/claude_desktop_config.json` | | **Windows** | `%APPDATA%\Claude\claude_desktop_config.json` | Add the `mcpServers` block (merge into any existing one): ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ **Fully quit and reopen** Claude Desktop — it only reads the config on startup (closing the window is not enough). ## Cursor Global config lives at `~/.cursor/mcp.json`; for a single project use `.cursor/mcp.json` in the project root. Same `mcpServers` shape as Claude: ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ You can also add it from **Settings → MCP → Add new global MCP server**, which opens the same file. New servers appear under Settings → MCP; toggle **imqueue** on if it is not already enabled. Or install it in one click: ▶ Add to Cursor ## VS Code GitHub Copilot's agent mode reads `.vscode/mcp.json` in your workspace (or run **MCP: Open User Configuration** for a global file). VS Code uses the `servers` key with an explicit transport `type` — **not** `mcpServers`: ~~~json { "servers": { "imqueue": { "type": "stdio", "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ Or add it in one line from a terminal: ~~~bash code --add-mcp '{"name":"imqueue","command":"npx","args":["-y","@imqueue/mcp"]}' ~~~ Or install it in one click (opens VS Code): ▶ Install in VS Code Open the Copilot Chat **Agent** mode and click the tools icon to confirm imqueue's tools are listed and enabled. ## Visual Studio Visual Studio 2022 (17.14+) reads a **`.mcp.json`** file — put it at your solution root (and add it to *Solution Items* to share it), or use the global `%USERPROFILE%\.mcp.json`. Same `servers` shape as VS Code: ~~~json { "servers": { "imqueue": { "type": "stdio", "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ Saving valid JSON restarts the Copilot agent and reloads the server. MCP tools are **disabled by default** — enable imqueue's tools from the Copilot Chat tools list. ## JetBrains For AI Assistant in any JetBrains IDE (IntelliJ IDEA, WebStorm, PyCharm, etc., 2025.1+): **Settings → Tools → AI Assistant → Model Context Protocol (MCP) → Add**, then paste the JSON. It uses the `mcpServers` shape: ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ If you already configured it for Claude Desktop, the dialog's **Import from Claude** button pulls the config across. For a project-scoped setup you can commit `.idea/mcp.json` instead. ## Windsurf Windsurf's Cascade reads `~/.codeium/windsurf/mcp_config.json` (on Windows, `%USERPROFILE%\.codeium\windsurf\mcp_config.json`). Same `mcpServers` shape; edit it via **Settings → Cascade → Manage MCP servers → View raw config**: ~~~json { "mcpServers": { "imqueue": { "command": "npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ ## Zed Zed calls them *context servers*, configured in `~/.config/zed/settings.json`: ~~~json { "context_servers": { "imqueue": { "command": { "path": "npx", "args": ["-y", "@imqueue/mcp"] } } } } ~~~ ## Other clients The @imqueue MCP server is a standard **stdio** server, so any MCP-capable client works. Whatever the client, you are giving it the same three facts: - **command:** `npx` - **args:** `["-y", "@imqueue/mcp"]` - **transport:** stdio (local subprocess) A few more clients and where their config lives: | Client | Config | Key | |---|---|---| | **Cline / Roo Code** | MCP Servers panel → *Edit Configuration* | `mcpServers` | | **Continue** | `~/.continue/config.yaml` | `mcpServers` | | **OpenAI Codex CLI** | `~/.codex/config.toml` | `[mcp_servers.imqueue]` (TOML) | | **Gemini CLI** | `~/.gemini/settings.json` | `mcpServers` | For Codex's TOML the same server looks like: ~~~toml [mcp_servers.imqueue] command = "npx" args = ["-y", "@imqueue/mcp"] ~~~ You can also find the server on the official MCP registry as **`org.imqueue/mcp`** if your client installs from there. ## Verify it worked 1. **Restart the client** (or reload its MCP config). Desktop apps usually need a full restart. 2. Open the client's **tools / MCP** list — you should see **imqueue** with its tools (`search_docs`, `create_service`, `fleet`, …). Enable them if the client disables new tools by default (VS Code and Visual Studio do). 3. Ask the agent to use one, e.g. *"use the imqueue MCP to search the docs for delayed jobs."* If the server does not appear or fails to start — especially the **`npx` not found** error common when Node is installed via `nvm` and the client is launched from the desktop — see [Safety & troubleshooting](https://imqueue.org/mcp/security/#troubleshooting). --- # MCP tools reference Source: https://imqueue.org/mcp/tools/ Published: 2026-07-22 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Every tool the @imqueue MCP server exposes — what it does, what it takes, what comes back, and an example prompt that triggers it. The server exposes its tools in three groups. **Documentation** and **scaffolding** tools work everywhere with no dependencies. The **CLI bridge** tools require [`@imqueue/cli`](https://imqueue.org/cli/) (`imq`) on your `PATH` — call `cli_status` first, and if it is missing either `cli_install` it or fall back to the offline `scaffold_*` tools. Which of them you get depends on how you connect. The **local** install has all thirteen. The **hosted** endpoint has six, all read-only: the documentation and scaffolding tools plus `local_install_guide`. The CLI-bridge tools act on your own machine — your files, your processes, your CLI config — which a server on Cloudflare's edge cannot reach, so it does not offer them at all. They are not in its tool list. You never call these by hand — your agent picks them based on their descriptions. This page is so you know what it *can* do, and what is safe. ## Documentation ### `search_docs` Search the official @imqueue docs (guides, tutorial, CLI manual, API reference, articles) and return the most relevant pages with their URLs. - **Input:** `query` (string) — what you want to find; `limit` (1–20, default 6). - **Returns:** a ranked list of matching pages with titles, sections and URLs. - **Side effects:** none (read-only; fetches imqueue.org). - **Example prompt:** *"How do I expose a method on an @imqueue service?"* ### `get_doc` Fetch the full markdown of a documentation page by its URL (as returned by `search_docs`), ready to read and quote. - **Input:** `url` (string) — an `imqueue.org` page URL. - **Returns:** the page as plain markdown. - **Side effects:** none. **Host-locked to `imqueue.org`** — it will not fetch any other host. - **Example prompt:** *"Read the getting-started guide and summarise the steps."* ### `list_packages` The main @imqueue packages with a one-line summary and install command, so the agent picks the right one before writing code. - **Input:** none. - **Returns:** the package catalog (e.g. `@imqueue/rpc`, `@imqueue/core`, `@imqueue/cli`) with install commands. - **Side effects:** none. - **Example prompt:** *"Which @imqueue package do I need for delayed jobs?"* ## Scaffolding (offline) ### `scaffold_service` Generate an idiomatic `@imqueue/rpc` service — an `IMQService` subclass with `@expose()`d, JSDoc-typed methods plus a bootstrap that starts it. - **Input:** `name` (string); `methods` (optional array of `{ name, description?, params?, returns? }`). - **Returns:** ready-to-save TypeScript. Omitting `methods` yields a starter template. - **Side effects:** none — returns code as text; writes nothing. - **Example prompt:** *"Scaffold an @imqueue user service with getUser and createUser."* ### `scaffold_client` @imqueue generates the **real** typed client from a **running** service, so types never drift. This tool returns the exact command to do that plus an illustrative usage snippet — it does not fabricate a client that could go stale. - **Input:** `service` (string); `methods` (optional, to shape the example). - **Returns:** the `imq client generate` command and an example call. - **Side effects:** none. - **Example prompt:** *"How do I get a typed client for my user service?"* ## CLI bridge tools The CLI bridge tools drive the real `imq` binary. Every call runs with **stdin closed and a timeout**, so a command that would prompt interactively fails fast with guidance rather than hanging your agent. ### `cli_status` Detect whether `imq` (@imqueue/cli) is installed and report its version. The agent should call this before any other CLI-bridge tool. - **Input:** none. **Read-only.** - **Example prompt:** *"Is the @imqueue CLI installed?"* ### `cli_install` Install `@imqueue/cli` globally via `npm install -g @imqueue/cli` when it is missing. - **Input:** `version` (optional npm version/tag, default `latest`). - **Side effects:** installs a global npm package (may need a writable prefix or elevated permissions). - **Example prompt:** *"Install the @imqueue CLI for me."* ### `cli_help` Run `imq [command] --help` and return the exact, version-accurate flags — the agent uses this to build a non-interactive `create_service` call. - **Input:** `command` (optional, e.g. `"service create"`). **Read-only.** - **Example prompt:** *"What flags does imq service create take?"* ### `create_service` Scaffold a real, provider-wired service via `imq service create`. - **Input:** `name`; `path?`; `flags?` (from `cli_help`); `cwd?`; **`apply?`**. - **Safety:** runs as a **dry-run by default** (shows the plan, writes nothing). A real run requires **`apply: true`** — it can write files, init git, configure CI and push to a remote, so an agent should only apply with your clear intent. - **Example prompt:** *"Create a payments service — show me the plan first."* ### `generate_client` Run `imq client generate ` to emit the real, fully-typed client. - **Input:** `service`; `path?`; `cwd?`. - **Requirement:** the target service must be **running** — the CLI introspects the live service. - **Example prompt:** *"Generate the typed client for the running UserService."* ### `fleet` Run `imq ctl ` over a directory of service repositories. - **Input:** `action` (`start` | `stop` | `restart` | `status`); `path?`; `services?`; `update?`; `calm?`; `verbose?`; `cwd?`. - **Safety:** `status` is read-only; `start` / `stop` / `restart` change running processes. - **Example prompt:** *"Start my local fleet and tell me what's running."* ### `config` Run `imq config ` to manage CLI configuration. - **Input:** `action` (`check` | `get` | `set` | `init`); `option?` (dot-path for nested keys); `value?`; `cwd?`. - **Safety:** `check` / `get` are read-only; `set` writes one value; `init` is interactive, so automation should prefer `set`. - **Example prompt:** *"Set my default CI provider to github-actions."* ### `logs` Work with logs of services started by `imq ctl`. - **Input:** `action` (`dump` (default) | `clean`); `services?`; `prefix?`; `cwd?`. - **Safety:** `dump` reads the current combined logs and exits — it **never follows/streams**, and output is capped so it can't flood the agent; `clean` deletes collected logs. - **Example prompt:** *"Show me the recent logs for the auth service."* ## Hosted endpoint only ### `local_install_guide` Registered **only on the hosted server** ([`mcp.imqueue.org`](https://imqueue.org/mcp/#hosted-endpoint)), where it is how an agent discovers that the CLI-bridge tools need a local install. The local server does not expose it — there is nothing left to install. - **Input:** none. - **Returns:** the exact steps to install the full MCP server on your machine. - **Side effects:** none — it returns instructions, it does not install anything. That is also why it is named `local_install_guide` rather than `install_locally`, as it was before 3.0.0: a tool's name has to describe what it actually does. - **Example prompt:** *"Why can't you start my fleet?"* The CLI-bridge tools above are **absent** from the hosted server's tool list — it does not list them and cannot run them. Until 3.0.0 it listed them and answered with install guidance instead, which was worse: an agent that picked `fleet` got prose rather than a fleet. See [Safety & troubleshooting](https://imqueue.org/mcp/security/). ## Read-only vs state-changing `@imqueue` MCP tools A quick map of which `@imqueue` MCP tools are safe to let an agent call freely, and which change your machine: | Read-only | Changes state | |---|---| | `search_docs`, `get_doc`, `list_packages` | `cli_install` (global npm install) | | `scaffold_service`, `scaffold_client` | `create_service` **with `apply: true`** | | `cli_status`, `cli_help` | `generate_client` (writes client files) | | `create_service` (default dry-run) | `fleet start/stop/restart` | | `config check/get`, `fleet status` | `config set`, `logs clean` | | `logs dump` | | Every tool declares this in machine-readable form too. Each one carries the MCP `readOnlyHint`, `destructiveHint` and `openWorldHint` annotations, so a client can decide what to run without asking you rather than guessing from the name. Tools that accept a mix of operations own their **worst** case: `fleet` is marked destructive because `stop` is, even though `status` is not. Every tool on the hosted endpoint is `readOnlyHint: true`. See [Safety & troubleshooting](https://imqueue.org/mcp/security/) for the full trust model, and [Agent workflows](https://imqueue.org/mcp/workflows/) for how these tools chain together in practice. --- # MCP agent workflows Source: https://imqueue.org/mcp/workflows/ Published: 2026-07-22 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) End-to-end recipes: what to ask your agent for, and how the @imqueue MCP tools chain together behind each request. **You do not script the `@imqueue` MCP server — you ask your agent for an outcome, and it picks the tool chain.** Each recipe below pairs a natural-language request with the `@imqueue` tools it actually invokes, from `search_docs` through `create_service` to `fleet`. Each recipe below is a natural-language request you make to your agent, followed by the tool chain it runs. You don't script these — the agent picks the tools from your prompt. Knowing the chain helps you phrase requests and understand what the agent is about to do. ## Build a service from a prompt > *"Build me an @imqueue user service with getUser, createUser and deleteUser, > and set it up as a real project."* A well-behaved agent will: 1. **`search_docs`** → *"create a service"* to ground itself in the current guide. 2. **`cli_status`** → is `imq` available? If not, **`cli_install`** (or drop to step 5's offline path). 3. **`cli_help`** `service create` → discover the exact flags so the run is non-interactive. 4. **`create_service`** (name `user`) — first as a **dry-run** so you see what it would create. 5. On your go-ahead, **`create_service`** again with **`apply: true`** to actually scaffold the project. If the CLI is not installed and you don't want it, the agent uses **`scaffold_service`** instead to hand you the `IMQService` code directly — no project wiring, but immediately usable. ## Generate a typed client > *"Give me a typed client for the UserService."* 1. **`cli_status`** → confirm `imq` is present. 2. **`generate_client`** `UserService` → emits the real, introspected client. The catch @imqueue is built around: the client is generated from a **running** service, so the types are always the truth. If the service isn't running, the agent will tell you to start it first (see the fleet recipe) — it can't and won't fabricate a client from guesses. If you only need to *see the shape*, **`scaffold_client`** returns an illustrative snippet without a running service. ## Run and inspect a local `@imqueue` fleet > *"Start all my services and show me if anything is failing."* 1. **`fleet`** `status` → what's already running (read-only). 2. **`fleet`** `start` (optionally `update: true` to `git pull` first, `calm: true` to start one at a time) → bring the fleet up. 3. **`logs`** `dump` → pull the recent combined logs (capped, never streaming) so the agent can spot errors. 4. If needed, **`fleet`** `restart` a specific service, then **`logs`** `dump` again. Because `logs` never follows/streams, the agent gets a bounded snapshot it can actually reason about instead of an endless tail. ## Learn the API without leaving your editor > *"How does @imqueue handle delayed jobs? Show me an example."* 1. **`search_docs`** *"delayed jobs"* → the relevant guide/API pages. 2. **`get_doc`** on the top hit → full markdown to read and quote. 3. Optionally **`list_packages`** if a specific package (e.g. `@imqueue/job`) is involved. This is the everyday use: authoritative answers from the current docs, inline, instead of hallucinated APIs from a training snapshot. ## Configure `@imqueue/cli` for a project > *"Set this project up to use GitHub Actions for CI."* 1. **`config`** `check` → is config initialized? 2. **`config`** `get` → read current values. 3. **`config`** `set` `ci.provider` `github-actions` → write the single value. The agent prefers `set` over the interactive `init`, which would time out in a non-interactive context. ## When the CLI isn't installed Everything degrades gracefully. With no `imq` on `PATH`: - **Docs** (`search_docs`, `get_doc`, `list_packages`) — fully available. - **Scaffolding** (`scaffold_service`, `scaffold_client`) — fully available; the agent hands you code and the client-generation command to run yourself. - **CLI bridge** — the agent offers **`cli_install`**, or continues with the offline tools above. So even a fresh machine with just the MCP server configured gets useful, grounded help — the CLI simply unlocks the "do it for real" tools. --- Next: the [Safety & troubleshooting](https://imqueue.org/mcp/security/) page covers the trust model behind `apply`, host-locking and read-only tools — worth a read before you let an agent run `create_service` or `fleet` unattended. --- # MCP safety model & troubleshooting Source: https://imqueue.org/mcp/security/ Published: 2026-07-22 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) The trust model behind the server — what it can and can't touch — and fixes for the setup problems you're most likely to hit. **The local `@imqueue` MCP server is a subprocess your own client launches over stdio with no account and no telemetry; the hosted `mcp.imqueue.org` endpoint cannot reach your filesystem or CLI at all, so it never registers the CLI-bridge tools.** This page states that trust model precisely — including what `create_service` does before you confirm it — and fixes the setup failures you are most likely to hit. ## Trust model You are connecting an autonomous agent to a tool that reads your docs and, with the CLI, can touch your machine. Here is exactly what the server can and cannot do. - **The `npx` install runs locally over stdio.** The server is a subprocess your client launches, with no account — your prompts and code never leave your machine except for documentation fetches. - **The hosted endpoint offers only tools it can run.** `mcp.imqueue.org` runs on Cloudflare and **cannot reach your filesystem or CLI**, so it does not register the CLI-bridge tools at all — they are absent from its tool list rather than present-but-inert. What it serves is six read-only tools: `search_docs`, `get_doc`, `list_packages`, `scaffold_service`, `scaffold_client` and `local_install_guide`. Each request is handled independently (no sessions, no stored state). Use it to explore; use the local install to build. - **Every tool declares its own blast radius.** All of them carry the MCP `readOnlyHint`, `destructiveHint` and `openWorldHint` annotations, so your client can decide what to run unattended instead of inferring it from a name. A tool that accepts several operations is marked by its **worst** one — `fleet` is destructive because `stop` is, `config` because `set` overwrites, `logs` because `clean` deletes. - **Network access is host-locked.** The only network calls are to `imqueue.org` (for `search_docs` / `get_doc` / the docs cache). `get_doc` explicitly refuses any other host, so it can't be steered into fetching arbitrary URLs. - **Read-only and state-changing tools are separated.** See the table below — the agent (and you) can tell at a glance which tools only read. - **`create_service` is a dry-run by default.** It writes nothing unless called with `apply: true`. Creating repos, configuring CI or pushing to a remote never happens silently. - **Interactive commands fail fast.** Every CLI call runs with stdin closed and a timeout, so a command that would block on a prompt returns guidance instead of hanging your agent indefinitely. - **Log output is bounded.** `logs dump` never follows/streams and its output is capped, so it can't flood the agent's context. - **No telemetry.** The server collects and phones home nothing. ### What each tool can touch | Read-only | Changes state | |---|---| | `search_docs`, `get_doc`, `list_packages` | `cli_install` (global npm install) | | `scaffold_service`, `scaffold_client` | `create_service` **with `apply: true`** | | `cli_status`, `cli_help` | `generate_client` (writes client files) | | `create_service` (default dry-run) | `fleet start/stop/restart` | | `config check/get`, `fleet status` | `config set`, `logs clean` | | `logs dump` | | If you want a purely read-only setup — great for exploring the docs — simply don't install `@imqueue/cli`; the CLI-bridge tools then stay dormant and only the docs and offline scaffolding tools are active. ## Local or hosted — which should you use? The local and hosted `@imqueue` MCP servers are not the same tool set, and the difference is not just *where the server runs*. The [hosted endpoint](https://imqueue.org/mcp/#hosted-endpoint) (`mcp.imqueue.org`) is a great zero-install way to explore the docs and scaffold snippets, with six read-only tools. The local install has all thirteen. For real development work, **the local `npx` install is the better choice** — here's why. ### Why run the MCP server locally instead of using the hosted endpoint? The local install is the full product. Because it runs on your machine over stdio, it can do the things that actually matter while building: scaffold provider-wired services **straight into your repo**, generate a typed client by introspecting your **running** service, and start/inspect your **local fleet** — none of which a remote server can reach. The hosted endpoint deliberately can't touch your project, and so it does not list those tools at all: if you connect to it and ask your agent to start your fleet, there is no `fleet` tool for it to call. ### Is the local server more private? Yes. Everything stays on your machine — the only network traffic is documentation fetches to `imqueue.org`. With the hosted endpoint your tool inputs travel to the server, so for proprietary code and context the local install keeps it local by default. ### Is local faster? Yes. Tool calls run in-process over stdio with no per-call network hop, so the agent gets answers with lower latency. The hosted endpoint adds an HTTP round-trip to every call. ### Does it keep working offline or behind a corporate firewall? Largely, yes. Offline scaffolding works with no network at all, and only the documentation tools need `imqueue.org`. The hosted endpoint requires reaching `mcp.imqueue.org`, so on locked-down networks the local install is more reliable. ### Will it match my installed CLI and flags? Yes — the local server drives *your* actual `imq` binary, so `create_service` and `generate_client` reflect your project's exact CLI version, selected providers and flags. The hosted server has no CLI at all, so it can only emit generic scaffolds. **Bottom line:** start on the hosted endpoint to explore; switch to the one-line local install the moment you're scaffolding real services or working with running ones. ## Troubleshooting ### The server doesn't appear in my client Almost always the client wasn't fully restarted, or the config has the wrong shape. Check: - **Restart the whole app**, not just the window (Claude Desktop, JetBrains, Visual Studio all cache the config at startup). - **Right key for the client.** Cursor / Claude / JetBrains / Windsurf use `mcpServers`; **VS Code and Visual Studio use `servers` with `"type": "stdio"`.** Copying the wrong one is the most common mistake. - **Enable the tools.** VS Code and Visual Studio disable newly added MCP tools by default — turn imqueue's tools on in the Copilot tools list. ### `npx` not found / server fails to start Desktop apps launched from your OS menu often **don't inherit your shell's `PATH`**, so if Node is installed via **`nvm`** the client can't find `npx`. Fix it by pointing at the absolute path: ~~~bash which npx # e.g. /home/you/.nvm/versions/node/v22.15.0/bin/npx ~~~ ~~~json { "mcpServers": { "imqueue": { "command": "/home/you/.nvm/versions/node/v22.15.0/bin/npx", "args": ["-y", "@imqueue/mcp"] } } } ~~~ (Use the `servers` shape for VS Code / Visual Studio.) A system-wide Node install avoids this entirely. ### On Windows the command won't launch Some Windows clients need the command wrapped: ~~~json { "command": "cmd", "args": ["/c", "npx", "-y", "@imqueue/mcp"] } ~~~ or use `npx.cmd` as the command. ### First launch is slow `npx -y @imqueue/mcp` downloads the package on first run, so the initial start can take a few extra seconds before tools appear. Subsequent launches are fast. To avoid the download entirely, install it globally (`npm i -g @imqueue/mcp`) and point `command` at `imqueue-mcp`. ### The CLI tools say `imq` isn't installed The CLI-bridge tools need [`@imqueue/cli`](https://imqueue.org/cli/). Ask the agent to run `cli_install`, or install it yourself with `npm i -g @imqueue/cli`. The docs and offline scaffolding tools work regardless. ### `config init` or a service create "hangs" / times out Those commands are interactive. The server deliberately runs with stdin closed, so they fail fast instead of hanging — that's expected. Use `config set` for individual values, and pass explicit `flags` (discovered via `cli_help`) to `create_service` so it runs non-interactively. ### `generate_client` can't find the service `imq client generate` introspects a **running** service. Start it first (e.g. via the `fleet` tool), then retry. ### Logs look truncated By design — `logs dump` caps its output so it can't overwhelm the agent. For full logs, read the service's own log files directly, or narrow the `services` argument. Still stuck? The server is open source — file an issue at [github.com/imqueue/mcp](https://github.com/imqueue/mcp/issues). --- # Agent recipes for @imqueue codebases Source: https://imqueue.org/agents/ Published: 2026-07-29 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) Machine-oriented, verifiable procedures an AI coding assistant can follow to make a change in an @imqueue codebase — each with the facts it relies on, the commands to prove it worked, and the ways it goes wrong. An **agent recipe** is written for a machine, not a reader. Where a blog article explains *why* an approach works and what the trade-offs feel like in production, a recipe states the contract precisely, gives the exact commands, and lists the failure modes an agent should expect — so an assistant can apply it to a codebase without inferring the parts nobody wrote down. Every recipe below assumes one mechanism, so it is worth stating once. An `@imqueue` RPC call is two messages over Redis — a request onto the callee's queue, a reply onto the caller's — and the queue name *is* the service class name: ~~~mermaid sequenceDiagram participant C as Caller (generated client) participant Q as Redis (@imqueue transport) participant S as UserService (extends IMQService) C->>Q: request onto queue "UserService" Note over Q: {method:"get", args:["42"], from:"caller-reply-queue"} Q-->>S: whichever instance asks first S->>S: run the @expose()d get(id) S->>Q: reply onto the caller's own queue Q-->>C: resolves await client.get("42") ~~~ An `@imqueue` call has no host, port or connection: the caller addresses a queue name, and if no instance of that service is running the request waits instead of failing — forever, unless `callTimeout` is set. Each recipe follows the same shape: - **When to apply this recipe** — the trigger, so an agent can rule it out fast. - **Facts these recipes rely on** — the API contracts the steps depend on, stated explicitly rather than left to be guessed from a signature. - **The recipes themselves** — numbered, copy-pasteable procedures. - **Verify** — commands that prove the change actually took effect. - **Failure modes** — what breaks, what it looks like, and what to do instead. ## Available recipes - **[Delayed & scheduled work](https://imqueue.org/agents/delayed-scheduled-work/)** — implementing "run this later": choosing between a delayed call, `@imqueue/job` and an external scheduler, the trailing-argument `IMQDelay` contract, self-re-arming recurrence, and the accuracy and cancellation limits to plan for. - **[Isolated imq CLI environments](https://imqueue.org/agents/isolated-imq-environments/)** — running several @imqueue projects on one machine without collisions, using `IMQ_CLI_HOME` for a dedicated CLI home per fleet, plus disposable sandboxes for CI. ## Why these pages are not in search results Each recipe deliberately covers the same ground as a human-facing article, so the recipes carry `noindex` to keep the two from competing for the same queries. The narrative versions are the ones written to be read and the ones that rank: [delayed and scheduled work](https://imqueue.org/blog/scheduled-work-without-a-job-system/) and [one isolated imq CLI home per project](https://imqueue.org/blog/isolated-imq-cli-environments/). Staying out of the search index does not make the recipes any less available to an agent. They are listed in [`/llms.txt`](https://imqueue.org/llms.txt), every page has a plain-markdown mirror at both `index.md` and `.md` (byte-identical — the ecosystem is split on which shape it should be, so this site serves both), and the [@imqueue MCP server](https://imqueue.org/mcp/)'s `get_doc` tool fetches them directly. ## Getting an assistant set up If you are pointing an AI assistant at @imqueue for the first time, start with [using AI coding assistants with @imqueue](https://imqueue.org/using-ai-assistants/) for a paste-ready context block, or install the [MCP server](https://imqueue.org/mcp/installation/) so your agent can search the docs and scaffold services itself. --- # Agent recipe: delayed & scheduled work Source: https://imqueue.org/agents/delayed-scheduled-work/ Published: 2026-07-25 Updated: 2026-07-29 Author: @imqueue maintainers (https://github.com/imqueue) Machine-oriented instructions for AI agents implementing "run this later" in an @imqueue codebase: picking between a delayed call, @imqueue/job and an external scheduler, the exact trailing-argument contract, verification commands and failure modes. These are operational instructions for AI agents asked to run work later — deferred, retried or recurring — in a codebase that already uses [`@imqueue`](https://imqueue.org/get-started/). A human-oriented walkthrough of the same material is at [/blog/scheduled-work-without-a-job-system/](https://imqueue.org/blog/scheduled-work-without-a-job-system/). ## When to apply this recipe Apply it when the user asks for work that runs later over machinery they already have: - "send / notify / charge / clean up **in N minutes or hours**"; - "**retry** this failed call later", with backoff; - "run this **every N minutes**" as a sweep or reconciliation; - "do we need **BullMQ / a job queue / cron** for this?" — check this recipe's decision table before adding a dependency. Do **not** apply it when the requirement is declarative scheduling (cron strings, timezones, calendar dates), operator control of the queue (priorities, attempt caps, dead-lettering, pause/resume, a dashboard), or a multi-step resumable workflow. Those need a real scheduler, a dedicated job system, or an orchestration engine — say so instead of approximating them. ## Choose the mechanism first | Requirement | Mechanism | | --- | --- | | Defer a method the service already exposes | Delayed RPC call — `IMQDelay` as the last argument | | Deferred/retried background work with a payload you own | `@imqueue/job` — `push(job, { delay })` | | Raw message, no RPC or job semantics | `@imqueue/core` — `send(queue, message, delayMs)` | | Recurring tick | Self-re-arming `@imqueue/job` chain, seeded out of band | | Cron strings, timezones, "skip missed runs" | External scheduler that triggers an ordinary call | | Priorities, attempt caps, dead-letter, dashboard | Dedicated job system — see [/blog/bullmq-alternatives/](https://imqueue.org/blog/bullmq-alternatives/) | ## Facts these recipes rely on Verified against `@imqueue/core`, `@imqueue/rpc` and `@imqueue/job` sources and by measurement against a live Redis. **The call contract.** Generated clients take **two** optional trailing parameters, in this order: `imqMetadata?: IMQMetadata`, then `imqDelay?: IMQDelay`. The delay is always last. `IMQDelay(timer, unit)` accepts a unit of `'ms' | 's' | 'm' | 'h' | 'd'`, defaulting to `'ms'`. Those two parameters are stripped from the request **by identity (`instanceof`), not by position**. All of the following are measured; do not guess between them: - **`method(data, undefined, delay)` — emit this form** on `@imqueue/rpc` **>= 3.4.0**. A trailing `undefined` on a delayed call is a placeholder and is never delivered, whether or not metadata is also passed, and however many trailing placeholders there are. So `method(a, undefined, undefined, delay)` sends `[a]`, and a skipped optional declared param falls back to its default. - `method(data, new IMQMetadata({ ... }), delay)` compiles and runs on **every** version. **Emit this form** when the installed version is `<= 3.3.0` or unknown, because there the placeholder survives into the request as a real argument (serialized `null`) and a method whose params are all required rejects the call with **`IMQ_RPC_INVALID_ARGS_COUNT`**. - On **3.3.1** only, the rule is narrower: one placeholder is dropped, and only when no metadata is passed. Treat 3.3.1 as "prefer the bag" too. - `method(data, delay)` **runs**, but fails type-check with **TS2345** (`IMQDelay` is not assignable to `IMQMetadata`) on every version. Do not emit it, and do not reach for `as any` to silence it — on 3.3.1 that cast changes what a skipped optional param delivers. - Nothing is dropped when there is no delay, on any version: `method(a, undefined)` delivers `null`, so a default does not fire. - The service-side arity check is `declared === received` when every declared parameter is required, and `declared >= received` when at least one is optional. So a method with an optional parameter accepts a call that *omits* trailing arguments, and a surviving placeholder that lands in an optional slot passes the check instead of being rejected. A count *above* the declared total is rejected either way. A passing call therefore proves nothing about a method with a different signature — do not generalise from it. **Caller-side.** `callTimeout` has no default, and unset means wait forever; a delay extends its budget rather than firing early. The pending promise's resolver lives only in the caller's memory, so never `await` a long delayed call inside a request handler — a restart loses the resolver while the reply still arrives. **Enqueue is not a durability confirmation.** `send(toQueue, message, delay?, errorHandler?)` takes milliseconds and resolves with a locally generated UUID *before* the write is confirmed — pass `errorHandler` to learn about failures. `push(job, { delay })` returns synchronously and takes no error handler, so a failed enqueue only reaches the queue's logger. **`@imqueue/job` handler return contract** — get this exactly right: | Handler outcome | Effect | | --- | --- | | returns positive number | re-scheduled after that many ms | | returns `0` | re-scheduled **immediately** — a hot loop, never use it to stop | | returns negative number, or nothing | stops; no re-schedule | | **throws** | re-scheduled with the delay the job was **originally pushed with** — so a job pushed *without* a delay is **dropped**, not retried | Catch handler errors explicitly and return a delay; do not rely on a throw. There is no declarative `attempts` policy and no dead-letter destination — carry the attempt counter in the job payload and write your own park-for-review table. **Delivery mode.** `safeDelivery` defaults to `false` in `@imqueue/core` and `@imqueue/rpc`, and to `true` through `@imqueue/job` (whose `safeLockTtl` maps to `safeDeliveryTtl`). The lease covers the hand-off only, so a process killed mid-handler still loses that attempt. At-least-once is the guarantee either way: make deferred handlers re-runnable. See [/blog/guaranteed-message-delivery-cost/](https://imqueue.org/blog/guaranteed-message-delivery-cost/). **Promotion path.** The producer parks the packed message in the sorted set `::delayed`, scored `Date.now() + delay`, plus an empty companion key `:::ttl` set with `PX NX`. That key's expiry fires a keyspace notification, and one elected watcher moves everything now due onto the ready list; failing that, workers sweep every `watcherCheckDelay` (default **5000 ms**). Default prefixes: `imq` for core/rpc, `imq-job` for jobs. Prompt promotion needs `notify-keyspace-events` to include `Ex`. **Accuracy is "no earlier than."** Pass whole integer milliseconds: a fractional delay fails to set the alarm key and waits for the next sweep, and an `IMQDelay` carrying an unrecognised unit string yields no delay at all. The due time comes from the *sending* process's clock and is compared against the *sweeping* one. **Absent features.** There is no cron, repeat or recurrence primitive anywhere in the ecosystem. There is also no handle for a single scheduled message: nothing cancels, reschedules or inspects one, and the only removal path is wholesale. If an action can be revoked, gate the handler with a state check rather than trying to unschedule it. ## Recipe: defer a call the service already exposes ~~~typescript import { IMQDelay, IMQMetadata } from '@imqueue/rpc'; const notifications = new NotificationClient({ callTimeout: 30_000 }); await notifications.start(); // fire-and-forget: do not await a 24h call in a request handler notifications .sendTrialEndingEmail( { userId, plan }, undefined, // metadata slot — skipped new IMQDelay(24, 'h'), // delay is always last ) .catch(err => logger.error('deferred call failed to enqueue', err)); ~~~ The service method needs no change: a delayed request carries only `from`, `method`, `args` and optional metadata, so validation, decorators and handlers behave exactly as for an immediate call. ## Recipe: retry with backoff you control ~~~typescript import JobQueue from '@imqueue/job'; type Sync = { orderId: string; attempt: number }; new JobQueue({ name: 'OrderSync' }) .onPop(async job => { try { await warehouse.sync(job.orderId); } catch (err) { job.attempt += 1; // counter lives in the payload if (job.attempt > 5) { await parkForReview(job); // your own dead-letter table return -1; // negative: stop } return 1000 * 2 ** (job.attempt - 1); // 1s, 2s, 4s, 8s, 16s } }) .start() .then(queue => queue.push({ orderId, attempt: 0 })) .catch(err => logger.error('OrderSync failed to start', err)); ~~~ ## Recipe: recurring sweep ~~~typescript import JobQueue from '@imqueue/job'; const EVERY_MINUTE = 60_000; const sweeper = new JobQueue>({ name: 'ExpireHolds' }); sweeper.onPop(async () => { await releaseExpiredHolds(); return EVERY_MINUTE; // re-arm; next tick is scheduled after the work }); await sweeper.start(); ~~~ Seeding rules — the part that breaks in production: 1. **Never seed on process boot unguarded.** Every process that pushes a seed starts its own chain, so N replicas rolling through a deploy leave N overlapping sweeps forever. 2. Seed from a one-off task, a migration step, or the scheduler the user already runs. 3. If it must happen at boot, gate it on a `SET NX` lease whose TTL spans several periods, retried on an interval, so a deploy cannot double-seed and a dead chain re-seeds when the lease lapses. 4. The period drifts: the next fire time is computed *after* the work finishes, so the real interval is work plus delay. Do not promise wall-clock alignment. 5. A chain is not a schedule. If a re-arm ever fails, the recurrence ends silently — instrument the age of the last completed run and alert on it. ## Verify ~~~bash # prompt promotion requires keyspace expiry events (look for E and x) redis-cli CONFIG GET notify-keyspace-events # what is parked right now, and when it is due (scores are due-time in ms) redis-cli --scan --pattern 'imq:*:delayed' redis-cli ZRANGE imq::delayed 0 -1 WITHSCORES redis-cli ZCARD imq-job::delayed # the alarm keys that trigger promotion redis-cli --scan --pattern 'imq::*:ttl' ~~~ Then confirm behaviour, not just configuration: schedule a short delay and measure the actual arrival (expect "no earlier than", plus up to `watcherCheckDelay` without notifications); kill a worker mid-handler and check the work is either re-run or recorded as lost, according to the delivery mode you chose; and run the handler twice to prove it is idempotent. ## Failure modes | Symptom | Cause | Fix | | --- | --- | --- | | `IMQ_RPC_INVALID_ARGS_COUNT` on a delayed call | on `<= 3.3.0` an explicit `undefined` in the metadata slot is forwarded as a real argument | upgrade to `>= 3.4.0`, or pass an `IMQMetadata` bag | | TS2345, `IMQDelay` not assignable to `IMQMetadata` | delay passed in the metadata slot | keep the delay in the last position | | A skipped optional param arrives as `null` and its default never fires | `undefined` serializes to `null`; placeholders are dropped only on a *delayed* call, and on 3.3.1 only one was dropped | pass the real value, declare the param nullable, or upgrade to `>= 3.4.0` | | Delayed call never settles in the caller | caller restarted, or no `callTimeout` set | set `callTimeout`; never `await` a long delay in a request handler | | Everything arrives ~5 s late | keyspace notifications lack `Ex`, so the polling fallback is doing the work | enable `notify-keyspace-events Ex`, or accept the `watcherCheckDelay` sweep | | Delay ignored entirely | fractional milliseconds, or an unrecognised `IMQDelay` unit | pass whole integer ms and a valid unit | | Job dropped instead of retried | handler threw, and the job was pushed without a delay | catch the error and return a delay number | | Worker spins at 100% CPU | handler returned `0`, re-scheduling immediately | return a negative number or nothing to stop | | N overlapping recurrences | every replica seeded the chain | seed out of band, or behind a `SET NX` lease | | Recurrence stopped silently | a re-arm failed and nothing restarts a broken chain | alert on the age of the last completed run, then re-seed | | Scheduled work lost when a worker died | `safeDelivery` is off by default in core/rpc | enable safe delivery for that queue, and make handlers re-runnable | | A queue's delayed messages never promote | no worker for that queue and no notifications available | keep a worker on the prefix, or ensure `Ex` notifications | | Cannot cancel a scheduled message | there is no per-message handle | gate the handler with a state check; treat scheduling as irrevocable | --- # Agent recipe: isolated imq CLI environments Source: https://imqueue.org/agents/isolated-imq-environments/ Published: 2026-07-23 Updated: 2026-07-30 Author: @imqueue maintainers (https://github.com/imqueue) Machine-oriented instructions for AI agents managing multiple @imqueue projects on one machine: how to create isolated imq CLI environments per fleet with IMQ_CLI_HOME, verify them, and avoid the known failure modes. These are operational instructions for AI agents working with the [`@imqueue/cli`](https://imqueue.org/cli/) tool (`imq`). A human-oriented walkthrough of the same material is at [/blog/isolated-imq-cli-environments/](https://imqueue.org/blog/isolated-imq-cli-environments/). ## When to apply this recipe Apply it when the user: - works on **two or more @imqueue projects** on the same machine; - reports crossed logs, services from another project being stopped, or config values leaking between projects; - needs **different VCS namespaces, providers, or auth tokens** per project or client; - asks for a **scratch/sandbox** CLI environment, or CLI isolation in CI. Do **not** apply it for a single project with default needs — the default `~/.imq` home is correct there. ## Facts these recipes rely on - `IMQ_CLI_HOME` is a **base directory**, not the home itself: with `IMQ_CLI_HOME=/x`, the CLI home is `/x/.imq`. Unset, the base is the user's OS home directory. - The CLI home contains exactly: `config.json` (global config, mode `0600`, may hold tokens), `templates/` (git clone of the templates repo, pinned to `templatesRef`), `custom-templates/`, and `var/` (`.log` per service + a `.pids` registry of `service:pid` lines used by `imq ctl` and `imq log`). All of it moves with `IMQ_CLI_HOME`. - The variable is read **once at process start** — set it in the environment of the `imq` invocation, never mid-process. - A fresh home needs no `mkdir`; `imq config init` / `imq config set` create the directory chain. - Config precedence: CLI flag → `./.imqrc.json` (committed, per service) → `$home/config.json` → interactive prompt (TTY only) → built-in default. See [/cli/configuration/](https://imqueue.org/cli/configuration/). - `imq ctl stop` **without `-s`**, run where no services are discoverable, stops **every pid tracked in the active home**. Scope stops carefully. - `~/.ssh` does **not** move with `IMQ_CLI_HOME` (override: `IMQ_SSH_DIR`). - `imq completions on|off` resolves the shell rc file against the CLI home — run completions commands **without** `IMQ_CLI_HOME` set. - Full env-var table: [/cli/configuration/#environment-variable-reference](https://imqueue.org/cli/configuration/#environment-variable-reference). ## Recipe: dedicated environment per project 1. Choose a home base per project, e.g. `$HOME/.imq-fleets/` or `/.imq-home` (ensure it is gitignored — `config.json` may hold tokens). 2. Prefix every `imq` invocation for that project: ~~~bash IMQ_CLI_HOME="$HOME/.imq-fleets/acme" IMQ_NO_UPDATE_CHECK=1 imq ~~~ 3. Initialize that environment's config non-interactively: ~~~bash IMQ_CLI_HOME="$HOME/.imq-fleets/acme" imq config set vcs.provider github IMQ_CLI_HOME="$HOME/.imq-fleets/acme" imq config set vcs.namespace acme-org IMQ_CLI_HOME="$HOME/.imq-fleets/acme" imq config set vcs.auth.token "$TOKEN" ~~~ (`imq config init` is interactive/TTY-only — prefer `config set` when running unattended.) 4. If setting up recurring use for the user, persist the prefix via a direnv `.envrc` in the project root or a shell function in their rc file: ~~~bash imqf() { local fleet="$1"; shift IMQ_CLI_HOME="$HOME/.imq-fleets/$fleet" IMQ_NO_UPDATE_CHECK=1 imq "$@"; } ~~~ ## Recipe: run two fleets side by side ~~~bash IMQ_CLI_HOME="$HOME/.imq-fleets/acme" imq ctl start -p ~/work/acme/services -c IMQ_CLI_HOME="$HOME/.imq-fleets/globex" imq ctl start -p ~/work/globex/services -c ~~~ - Same-named services in different fleets no longer collide (separate `var/`). - Always pass the **matching home** when running `imq ctl stop` / `imq log` for a fleet. - CLI isolation does not isolate infrastructure: same Redis + same service name = one shared worker queue. If both fleets run simultaneously, give each its own Redis port or database number in the services' own config. ## Recipe: disposable sandbox ~~~bash IMQ_CLI_HOME="$(mktemp -d)" imq # discard the dir afterwards ~~~ Use for testing config changes, custom templates, or any experiment that must not touch the user's real `~/.imq`. In CI, use a workspace-local path plus `IMQ_NO_UPDATE_CHECK=1`. ## Verify the environment ~~~bash IMQ_CLI_HOME= imq config check # exit 0 = config initialized IMQ_CLI_HOME= imq config get --json # inspect effective global config IMQ_CLI_HOME= imq ctl status -p # tracked services for THIS home ls -la /.imq # config.json, templates/, var/ ~~~ ## Failure modes | Symptom | Cause | Fix | | --- | --- | --- | | Wrong project's services were stopped | `imq ctl stop` without `-s` against a shared home falls back to all tracked pids | Use one home per fleet; scope stops with `-s` or `-p` | | Logs empty / overwritten | Same service name in two fleets sharing a home; logs truncate on every start | Separate `IMQ_CLI_HOME` per fleet | | Scaffold went to the wrong VCS org | Global `vcs.namespace`/token shared across projects | Per-fleet home with its own `config.json` | | Templates flip between branches | Two projects with different `templatesRef` share one templates clone | Separate homes (each keeps its own clone) | | `IMQ_CLI_HOME` "ignored" | Variable exported after process start, or set for a different process | Set it in the environment of the `imq` invocation itself | | Completions written to the wrong file | `completions on` run with `IMQ_CLI_HOME` set | Re-run without the variable | | Git push auth fails in sandbox | `~/.ssh` does not move with the home | Set `IMQ_SSH_DIR`, or use HTTPS transport with a token | --- # @imqueue vs NATS: a framework and a transport are not the same choice Source: https://imqueue.org/blog/imqueue-vs-nats/ Published: 2026-08-03 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) **These are not the same kind of thing, and the honest comparison starts there.** NATS is a messaging system — a server you run, with subjects, queue groups and request-reply built in, and clients in roughly forty languages. `@imqueue` is an RPC framework for Node.js and TypeScript that uses a message queue underneath. Asking "NATS or @imqueue" is really asking two questions: *do I want the contract between my services generated for me or written by me*, and *is my fleet Node-only*. If your services are polyglot, NATS wins on that fact alone and the rest of this post is background. If they are all Node and TypeScript, the interesting difference is how much plumbing you end up owning. (Details about NATS reflect its documented behaviour at the time of writing; check [nats.io](https://nats.io/) for the current state.) ## What each one actually gives you NATS gives you a transport with excellent primitives. Publish to a subject, subscribe with wildcards, and `request()`/`reply()` for request-response. Put several subscribers in the same **queue group** and the server delivers each message to exactly one of them — competing consumers, which is load balancing without a load balancer. Core NATS is fire-and-forget: if nobody is listening, the message is gone. **JetStream** is the persistence layer on top, and that is where you go for at-least-once delivery, replay, and streams that outlive their consumers. What NATS does not give you is a contract. You decide the subject naming scheme, the payload encoding, how errors travel back, what a timeout means, and how the caller knows the shape of the reply. Those are all real decisions and NATS is deliberately unopinionated about every one of them. `@imqueue` starts at the other end. A service is a class; methods marked `@expose()` are callable remotely; the service describes its own signatures at runtime, and the CLI generates a typed client from a *running* service. There is no subject scheme to design because the queue name is the service name, and no payload format to choose because it is JSON. You get less transport flexibility and a contract you did not have to write. ## The same call, both ways With NATS you own both halves. A minimal request-reply service: ~~~typescript import { connect, JSONCodec } from 'nats'; const nc = await connect({ servers: 'nats://localhost:4222' }); const jc = JSONCodec(); // "user.get" is a naming convention you invented and now have to maintain. const sub = nc.subscribe('user.get', { queue: 'user-service' }); for await (const msg of sub) { const { id } = jc.decode(msg.data) as { id: string }; msg.respond(jc.encode({ id, name: 'Jane Doe' })); } ~~~ and the caller: ~~~typescript const reply = await nc.request('user.get', jc.encode({ id: '42' }), { timeout: 2000 }); // `user` is whatever you assert it is. Nothing checked it. const user = jc.decode(reply.data) as { id: string; name: string }; ~~~ Note what the `as` is doing: it is a promise you are making to the compiler about data that arrived over a network. If the service changes `name` to `fullName`, this code compiles and fails at runtime. The `@imqueue` equivalent is the service alone: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; import { UserObject } from './types/UserObject.js'; export class UserService extends IMQService { /** * Returns a user by identifier * * @param {string} id - user identifier * @return {Promise} - the matching user */ @expose() public async get(id: string): Promise { return this.users.find(user => user.id === id) || null; } } ~~~ The caller is generated: ~~~bash imq client generate UserService ./src/clients ~~~ ~~~typescript import { UserClient } from './clients/UserService.js'; const client = new UserClient(); await client.start(); const user = await client.get('42'); // UserObject | null, checked at compile time ~~~ Rename `name` to `fullName` in the service, regenerate, and the call site fails to compile. That is the difference the whole comparison turns on — not throughput. ## NATS and `@imqueue` delivery semantics, precisely Delivery semantics is the part where a loose comparison does real damage, so: - **Core NATS is at-most-once.** No subscriber, no message. That is a feature for telemetry and a hazard for a call you needed to happen. - **NATS JetStream is at-least-once** by default, with message deduplication windows and per-consumer acknowledgement, and it is the layer you would compare with a queue rather than core NATS. - **`@imqueue` is at-least-once.** A request waits on its queue until a consumer appears rather than failing immediately — which is genuinely useful across restarts, and which also means "no answer yet" and "nothing will ever answer" look identical to the caller. That is what `callTimeout` is for. At-least-once anywhere means the same thing for your code: **exposed methods should be idempotent.** Most reads already are; writes need a natural key or a dedupe check. ## What you operate NATS is a single Go binary with clustering, superclusters and leaf nodes, and it is genuinely good at being run. JetStream adds storage to manage — file or memory, with replicas — so the operational picture grows when you enable it. `@imqueue` needs Redis, which most Node fleets already run for something else. That is the whole infrastructure requirement, and it is a deliberate ceiling: `vendor` defaults to `'Redis'` and is currently the only supported value, with `IMessageQueue` as the documented seam for another adapter. If you already run NATS and would rather not add Redis, that is a real argument and `@imqueue` does not have an answer for it today. For scaling Redis itself rather than adding a second bus, see [the horizontally scalable broker recipes](https://imqueue.org/blog/horizontally-scalable-redis-broker/). ## Where NATS is the better choice - **Polyglot fleets.** Node, Go, Rust, Python and Java services on one bus. `@imqueue` is Node.js and TypeScript only, and no amount of framework quality changes that. - **You need streaming, replay or fan-out.** JetStream retains messages and lets consumers replay from a position. `@imqueue` is a call-and-reply framework, not a log. - **Very high message rates with small payloads**, where a purpose-built messaging server's numbers matter to you and you are prepared to own the contract layer yourself. - **A permissive licence is required.** NATS is Apache-2.0. `@imqueue` is GPL-3.0 with a [commercial licence](https://imqueue.com/license/) for shipping inside closed-source products — which is a non-issue for internal services and a real decision if you distribute software. - **You already run NATS.** Adding a second transport to get typed clients is rarely worth it. Generate types from your own contracts instead. ## Where @imqueue's model costs you - **Redis only, today.** One transport, no pluggable adapters shipped. - **Node.js and TypeScript only.** A polyglot fleet is out of scope. - **No streaming, no replay, no retention.** Different problem, different tool. - **Client generation needs the service running.** It works by asking the live service to describe itself, which is what removes the IDL — and it means client generation is a step in your dev loop rather than a build-time artefact from a file. - **The queue-name-is-the-address model is less flexible** than subject wildcards. There is no `user.*` to subscribe to. ## Quick comparison | | @imqueue | NATS (core) | NATS JetStream | |---|---|---|---| | Kind of thing | RPC framework | Messaging system | Persistence layer on NATS | | Languages | Node.js / TypeScript only | ~40 client languages | ~40 client languages | | Infrastructure | Redis | NATS server (Go binary) | NATS server + storage | | Addressing | Queue name = service class | Subjects, with wildcards | Subjects + streams | | Load balancing | Competing consumers on a queue | Queue groups | Consumer groups | | Service discovery | Not needed | Not needed | Not needed | | Contract | Generated from the running service | You design it | You design it | | Payload | JSON, handled for you | You choose and encode | You choose and encode | | Typed call sites | Yes, generated | Hand-written or codegen you add | Hand-written or codegen you add | | Contract drift caught | Compile errors after regenerating | At runtime | At runtime | | Delivery | At-least-once | At-most-once | At-least-once | | Replay / retention | No | No | Yes | | Licence | GPL-3.0 / commercial | Apache-2.0 | Apache-2.0 | ## How to choose - **Choose NATS** if your fleet is or will be polyglot, if you need streaming and replay, if you already run it, or if a permissive licence is a requirement — and accept that the contract between services is yours to design and keep correct. - **Choose @imqueue** if your services are Node.js and TypeScript, you want the typed client generated rather than written, and you would rather run Redis than design a subject scheme, an encoding and an error convention. They are not competitors so much as different amounts of decision-making. NATS hands you excellent primitives and trusts you; `@imqueue` hands you a contract and constrains you. ## Frequently asked questions about @imqueue and NATS ### Is NATS a replacement for @imqueue? Not directly — NATS is a transport and `@imqueue` is a framework over one. The comparable pairing is "NATS plus whatever RPC conventions you write" against "`@imqueue` on Redis". If you only need messaging primitives, NATS alone is the smaller answer. ### Can @imqueue use NATS as its transport? Not today. `vendor` defaults to `'Redis'` and is currently the only supported value. `IMessageQueue` is the documented interface an adapter would implement, so it is a seam rather than a wall, but no NATS adapter ships. ### Does NATS give me typed clients? No. NATS delivers bytes; the types on either side are yours to declare and keep in step, usually with a shared package, protobuf, or a schema registry you run. That is the work `@imqueue` removes by generating the client from the live service. ### Is NATS faster than @imqueue? For raw message throughput on small payloads, a purpose-built Go messaging server is the safer bet, and no head-to-head benchmark on one rig would settle it honestly. For most internal services the handler dominates either way — the database call is the cost, not the hop. ### Which has better delivery guarantees? JetStream and `@imqueue` are both at-least-once, so neither lets you skip idempotency. Core NATS is at-most-once and is the odd one out: it will drop a message rather than wait for a consumer. ### Can I use both in the same system? Yes, and it is a reasonable split: NATS for events, streams and anything crossing a language boundary; `@imqueue` for typed calls between your Node services. Decide deliberately which owns a given call path, so you are not operating two RPC layers over the same traffic. --- Comparing more than one option? The [comparison matrix](https://imqueue.org/compare/) puts every alternative we have written up side by side. For the transport model itself see [RPC over Redis in Node.js](https://imqueue.org/blog/rpc-over-redis-nodejs/), and for the framework-level comparisons rather than the transport one, [@imqueue vs NestJS](https://imqueue.org/blog/imqueue-vs-nestjs/) and [vs Moleculer](https://imqueue.org/blog/imqueue-vs-moleculer/). Ready to try it? [Get started](https://imqueue.org/get-started/). --- # How Node.js services talk to each other in 2026: the honest options Source: https://imqueue.org/blog/nodejs-service-communication-options-2026/ Published: 2026-08-03 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) **There are six approaches in real use, none of them wrong, and the choice is decided by two questions rather than by a feature table.** Is your fleet Node-and-TypeScript-only or polyglot? And do you want the contract between services generated, declared, or negotiated by convention? Everything else — throughput, tooling, how fashionable something is — moves the answer far less than those two. This is written by the maintainers of one of the six, so read the section about it sceptically. We have tried to make it the section that is hardest on itself. ## The six ### 1. REST over HTTP The default, and the one nobody has to justify. Every language speaks it, every proxy understands it, every tool can call it, and a new engineer needs no onboarding. The cost is that HTTP is a document-transfer protocol being used as a function call. You hand-write a client or generate one from OpenAPI you also hand-write; you invent conventions for errors, pagination and partial responses; and the type safety between two TypeScript services is whatever you took the trouble to arrange. For *internal* calls you are also paying for a load balancer, service discovery or DNS-based addressing, and a retry policy — infrastructure whose only job is to find the other service. **Right when:** your consumers are outside your control, or the fleet is polyglot and you want the lowest-friction thing that works everywhere. We have written up [why internal APIs do not need REST](https://imqueue.org/blog/internal-apis-dont-need-rest/), which is the argument against it in this specific setting — not against REST at the edge, where it is usually correct. ### 2. gRPC The serious answer for cross-language RPC. Protobuf over HTTP/2, a written schema that is language-neutral and independent of any implementation, generated clients and servers in every language that matters, streaming, deadlines, and a well-understood operational story. The cost is the `.proto` file and everything around it: a build step, a schema repository, versioning discipline, and a code-generation pipeline in CI. For a fleet that is entirely TypeScript, you are maintaining a second type system alongside the one the compiler already has. **Right when:** you cross a language boundary, or you need the schema to be an artefact that outlives any one implementation. See [gRPC vs message-queue RPC](https://imqueue.org/blog/grpc-vs-message-queue-rpc/) for the detailed comparison, including where gRPC is straightforwardly better. ### 3. tRPC The best answer to a *different* question. tRPC gives end-to-end type safety with no code generation at all — the client's types are inferred from the server's router through the TypeScript compiler. For a front-end talking to its own back-end in one repository, it is close to ideal. The cost is that the mechanism is compile-time type inference across a shared codebase, so it wants a monorepo and a build relationship between caller and callee. That is a natural fit for a web app and its API, and an awkward one for services deployed independently on their own release cycles. **Right when:** a TypeScript front-end calls a TypeScript back-end you build together. [@imqueue vs tRPC](https://imqueue.org/blog/imqueue-vs-trpc/) goes further into where the boundary sits. ### 4. NATS (or another message bus, directly) Excellent primitives, honestly presented: subjects with wildcards, queue groups that give you competing consumers, request-reply built in, clients in about forty languages, and JetStream when you need persistence and replay. Kafka, RabbitMQ and Redis Streams occupy nearby ground with different trade-offs. The cost is that a transport is not a contract. You design the subject naming scheme, the payload encoding, the error convention, what a timeout means, and how a caller learns the shape of a reply. Those are all decisions you will make either deliberately now or accidentally over eighteen months. **Right when:** the fleet is polyglot, you need streaming or fan-out, or you already run it. [@imqueue vs NATS](https://imqueue.org/blog/imqueue-vs-nats/) has the detail. ### 5. A full framework — NestJS, Moleculer Buy the whole platform. NestJS brings dependency injection, modules, a mature ecosystem and transport adapters; Moleculer brings a service broker with pluggable transporters, a built-in registry, load-balancing strategies, circuit breakers and an API gateway. Both are actively maintained and both are more capable, in raw feature count, than anything else on this list. The cost is surface area: more to learn, more to configure, more opinions to work within, and — in both cases — type safety between services that you assemble yourself rather than get for free. **Right when:** you want breadth and built-in resilience features, and you are comfortable owning the typing story. [vs NestJS](https://imqueue.org/blog/imqueue-vs-nestjs/) · [vs Moleculer](https://imqueue.org/blog/imqueue-vs-moleculer/) ### 6. RPC over a message queue — what @imqueue does A service is a class. Methods marked `@expose()` are callable remotely. The service describes its own signatures at runtime, so a typed client is *generated from the running service* — no schema file, no IDL, no hand-written SDK. The queue name is the address, so instances of a service compete for messages on it, which removes service discovery and the internal load balancer from the request path. The honest costs, in order of how often they matter: - **Node.js and TypeScript only.** A polyglot fleet rules this out immediately, and nothing about the framework's quality changes that. - **Redis only, today.** `vendor` defaults to `'Redis'` and is currently the only supported value. `IMessageQueue` is the documented seam for another adapter, but none ships. - **At-least-once delivery**, so exposed methods need to be idempotent. Reads usually already are; writes need a natural key or a dedupe check. - **A down service means a waiting call, not a failing one.** Useful across restarts; it also means "no answer yet" and "nothing will ever answer" look identical to the caller, which is what `callTimeout` exists for. - **Client generation needs the service running.** That is the mechanism that removes the IDL, and it makes generation a step in the dev loop rather than a build artefact from a file in git. - **GPL-3.0**, with a [commercial licence](https://imqueue.com/license/) for shipping inside closed-source products. A non-issue for internal services; a real decision if you distribute software. - **No streaming, no replay, no retention.** It is a call-and-reply framework, not a log. **Right when:** your services are all Node and TypeScript, they call each other a lot, and you would rather run Redis than design and maintain a contract layer. ## The two questions Everything above collapses into this: **Is the fleet polyglot?** If yes — now or credibly within a year — you are choosing between gRPC, NATS and REST, and the rest of the list is out. This is the question that eliminates most options, and it is the one teams most often answer optimistically. **Where does the contract come from?** Three real answers: | Contract source | Approaches | What it costs you | |---|---|---| | **Declared** in a schema you write | gRPC, OpenAPI-first REST | A build step, a schema repo, versioning discipline — and a second type system if you are all TypeScript | | **Inferred** by the compiler | tRPC | A build relationship between caller and callee, which in practice means a monorepo | | **Generated** from the implementation | @imqueue | The generator has to reach a running service, and you are tied to its language | | **By convention** — none of the above | REST without a schema, raw NATS, most framework setups | Nothing up front, and drift caught at runtime, in production, by a caller | That last row is where most systems actually are. It is not a mistake; it is a default, and it is worth choosing on purpose rather than arriving at. ## Side by side | | Languages | Contract | Infra in the call path | Delivery | Licence | |---|---|---|---|---|---| | REST | Any | Convention or OpenAPI | LB / discovery / DNS | At-most-once per attempt | n/a | | gRPC | Any | Declared (`.proto`) | LB or mesh | At-most-once per attempt | Apache-2.0 | | tRPC | TS only | Inferred | HTTP server | At-most-once per attempt | MIT | | NATS | ~40 | Yours to design | NATS server | At-most-once (core) | Apache-2.0 | | NestJS / Moleculer | JS/TS | Yours to assemble | Depends on transport | Depends on transport | MIT | | @imqueue | Node/TS only | Generated | Redis | At-least-once | GPL-3.0 / commercial | The full matrix, with every row linked to a detailed comparison, is at [/compare/](https://imqueue.org/compare/). ## What we would actually tell you to pick If you are polyglot, use gRPC for calls and NATS for events, and stop reading framework comparisons. If you are all TypeScript and the pain you feel is *writing and re-writing clients*, that is the specific problem generated-from-the-service RPC solves, and it is worth thirty minutes of [Get started](https://imqueue.org/get-started/) to see whether the model fits. If the pain you feel is anything else — deployment, observability, database coupling, an unclear service boundary — none of the six will fix it, and choosing between them is a way of not working on it. [Splitting a monolith along the right seam](https://imqueue.org/blog/monolith-to-services-first-extraction/) is usually the higher-value work. ## Frequently asked questions about Node.js service-to-service communication ### What is the best way for Node.js microservices to communicate in 2026? There is no single best. For a polyglot fleet, gRPC for calls and a message bus for events is the safe default. For an all-TypeScript fleet, RPC over a message queue with generated clients removes the most code. For a front-end calling its own back-end, tRPC. The polyglot question decides more than any other. ### Is REST still fine for internal service calls? Yes — it is never the wrong answer, only sometimes the expensive one. The cost is hand-written clients, invented conventions, weak typing between two services that could have had strong typing, and the load balancer and discovery layer that exist only to find the other service. ### Do I need gRPC if all my services are TypeScript? Usually not. gRPC's central advantage is a language-neutral schema, and if there is no language boundary you are maintaining a second type system beside the one the compiler already gives you. It is still the right call if the schema needs to outlive the implementation. ### Can I use tRPC between back-end services? You can, and it works, but the mechanism is compile-time type inference across a shared codebase — so it pulls toward a monorepo and a build relationship between caller and callee. Independently deployed services on separate release cycles fit it badly. ### When is @imqueue the wrong choice? When your fleet is not entirely Node.js and TypeScript; when you cannot run Redis or already run a different bus and do not want a second; when you need streaming, replay or retention; or when GPL-3.0 does not work for you and a commercial licence is not on the table. Those are disqualifying, not inconvenient. ### Does at-least-once delivery mean duplicate work? It means duplicates are possible, so exposed methods should be idempotent. Most read methods already are. Writes need a natural key or a dedupe check — which is work, and it is the honest price of not losing calls when a consumer restarts. --- Each option above has its own detailed comparison; [/compare/](https://imqueue.org/compare/) is the index. If you have decided the generated-client model fits, [Get started](https://imqueue.org/get-started/) is about five minutes to a typed remote call. --- # One notification, every replica: the LISTEN/NOTIFY duplicate problem Source: https://imqueue.org/blog/postgres-notify-duplicate-listeners/ Published: 2026-07-31 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) `LISTEN`/`NOTIFY` is the cheapest way to get change events out of PostgreSQL and into a Node process. No broker, no extra infrastructure, a trigger and a few lines of `pg`. It works beautifully on your laptop, where there is one process. Then you scale to three replicas, and every notification is handled three times. The reason is stated plainly in the Postgres documentation, and is easy to read past: `NOTIFY` delivers to **every** session that has issued `LISTEN` on that channel. It is a publish/subscribe broadcast. It has no consumer groups, no partitions, no acks, no concept of a message being *taken* by one subscriber. Every listener gets its own copy, and none of them can tell how many others also got one. Here is three replicas of the same service, one notification, nothing else changed: ~~~ PUBLISHED order=1001 A HANDLED order=1001 at ...993230 B HANDLED order=1001 at ...993230 C HANDLED order=1001 at ...993230 ~~~ Same order, same millisecond, three times. If the handler sends a welcome email, three emails go out. If it charges a card, you have a support ticket. What makes this a nasty class of bug is that nothing anywhere reports a problem. Every replica did exactly what it was told. There is no error to catch, no metric that moves, and the behaviour is *correct* on a single instance — which is where it gets written and tested. The failure mode arrives with the second replica, months later, as a business problem rather than an engineering one. ## Why the obvious fixes are worse than they look **Elect a listener with configuration.** Give one replica `IS_LISTENER=true` and have the others skip `listen()`. It works, and it converts a duplicate-work bug into a single point of failure: when that pod is rescheduled, nobody is listening, and — because `NOTIFY` has no backlog — the notifications sent while it was down are gone for good. You have also made your deployment topology special, which tends to be discovered by whoever is on call. **Deduplicate in the handler.** Keep a `processed_events` table, insert the event id, let the unique constraint reject the duplicates. This is a genuinely sound pattern and you may want it anyway for idempotency — but as a fix for this problem it means all N replicas do all the work and then N−1 of them throw it away. It also needs a stable id in every payload, which trigger-generated notifications frequently do not have. **Advisory locks.** `pg_try_advisory_lock` is the right primitive and the wrong scope: a session-level advisory lock is held until the session ends, so you are back to writing the liveness and handover logic yourself, which is the actual hard part. The thing you want is for exactly one process to be the listener at any moment, chosen automatically, with the guarantee that if it dies another one takes over. That is what `@imqueue/pg-pubsub` calls `singleListener`, and it is on by default: ~~~typescript import { PgPubSub } from '@imqueue/pg-pubsub'; const pubSub = new PgPubSub({ connectionString }); // singleListener: true is the default -- this is the same thing: // new PgPubSub({ connectionString, singleListener: true }) pubSub.on('connect', async () => { await pubSub.listen('OrderCreated'); }); pubSub.channels.on('OrderCreated', payload => { // runs in exactly one process across the whole fleet }); await pubSub.connect(); ~~~ The same three replicas, the same single notification: ~~~ PUBLISHED order=2002 A LISTENING A active=true A HANDLED order=2002 at ...015018 B active=false C active=false ~~~ One handler. B and C connected fine, and never subscribed. ## The mechanism, because it decides how it fails Anything claiming "only one process will do this" is making a distributed-systems promise, and the interesting question is always what happens when the holder dies. So it is worth knowing exactly what this is built from — it is much simpler than you might expect. There is one table. Acquiring the lock is one statement: ~~~sql INSERT INTO pgip_lock.lock (channel, app) VALUES ($channel, $app) ON CONFLICT (channel) DO UPDATE SET app = pgip_lock.deadlock_check(pgip_lock.lock.app, $app) ~~~ The row *is* the lock, and `channel` is its primary key, so the second process to try this hits the conflict and lands in `deadlock_check`: ~~~sql CREATE FUNCTION pgip_lock.deadlock_check(old_app TEXT, new_app TEXT) RETURNS TEXT LANGUAGE PLPGSQL AS $$ DECLARE num_apps INTEGER; BEGIN SELECT count(query) INTO num_apps FROM pg_stat_activity WHERE application_name = old_app; IF num_apps > 0 THEN RAISE EXCEPTION 'Duplicate channel for app %', new_app USING DETAIL = 'LOCKED'; END IF; RETURN new_app; END; $$ ~~~ That is the whole design, and the good idea in it is the `FROM pg_stat_activity`. Each connection sets its `application_name` to a UUID on connect, so the lock row records *which connection* holds it. To decide whether the current holder is still alive, the function does not consult a heartbeat, a lease, or a TTL — it asks Postgres whether that connection is still there. Which means the liveness detector is the same component as the message bus and the lock store. There is no separate thing to be up, no clock skew between a lease writer and a lease reader, and no window in which a process believes it holds a lock that has actually expired. If the holder's connection is gone, `count` is `0`, the `UPDATE` proceeds, and the newcomer owns the row. If the connection is alive, the `INSERT` raises and the caller reports `LOCKED`. Handover has two paths, and they perform very differently. **A clean exit** deletes the lock row. There is a deferred constraint trigger on that table whose only job is `PERFORM PG_NOTIFY(OLD.channel, '1')` — so the release is itself a notification, on a private internal channel that the standby processes are already listening to. They wake up and race for the row immediately. Failover is effectively instant: ~~~ A LISTENING at ...528023 A active=true A SIGTERM -> releasing B active=false B LISTENING at ...532001 B HANDLED order=3003 at ...535040 ~~~ `SIGTERM` landed on A about four seconds after it took the lock, and B was subscribed within milliseconds of the release — the trigger's notification doing the work, nowhere near the 30-second retry timer. **A crash** deletes nothing. `SIGKILL`, an OOM kill or a lost node leaves the row in place with a dead connection recorded in it, and since no `DELETE` happened, no `PG_NOTIFY` fired, so nothing tells the standbys to try again. The only thing that recovers this is a retry timer, ticking every `acquireInterval` — **30 seconds by default**. Measured, holder killed with `SIGKILL`: | `acquireInterval` | time to takeover | |---|---| | `30000` (default) | 27.1s | | `5000` | 3.0s | Those 27 seconds are a real gap during which nobody is listening and every notification sent is lost, because `NOTIFY` has no backlog. That is the number to have an opinion about before this goes to production. Tuning it down is one option: ~~~typescript const pubSub = new PgPubSub({ connectionString, acquireInterval: 5000 }); ~~~ It is not free — every listener retries every interval, so the cost is a small query per standby per tick, and the README is direct about the trade-off being between system load and reliability. A few seconds is usually the right answer; 30 is a sensible default only because it assumes clean shutdowns are the normal case, which is true right up until it isn't. Which makes graceful shutdown load-bearing here rather than merely tidy. Signal handling is **opt-in** since 3.0.0 — importing the package does not take over your process lifecycle: ~~~typescript const pubSub = new PgPubSub({ connectionString, handleSignals: true }); // or, if you run your own shutdown sequence: // await pubSub.destroy(); ~~~ Without one of those, every deploy costs you a takeover delay, on a channel nobody is listening to. The general shape of that problem, and the drain that goes with it, is [graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/). ## `listen()` resolving does not mean you are listening This is the API's sharpest edge. Under `singleListener`, `listen()` on a channel another process already owns is not an error — it is the expected outcome for every replica but one. So it resolves quietly, having subscribed to nothing: ~~~typescript await pubSub.listen('OrderCreated'); // resolves in all three replicas ~~~ There is no rejection and no warning at that call site, by design: `listen()` is retried by the timer, by the release notification and by reconnects, so logging there would produce noise on every tick. Ask explicitly instead: ~~~typescript pubSub.isActive('OrderCreated'); // true only in the holder pubSub.activeChannels(); // channels this process actually owns pubSub.inactiveChannels(); // known, but owned by someone else ~~~ Worth wiring into a health endpoint, because "we are up" and "somebody is listening" are different questions and only the second one matters here. ## `singleListener` does not spread channels across replicas The natural assumption — mine included, until it was measured — is that because locks are per-channel, three channels across three replicas will settle into roughly one channel each. They do not. Three channels, three replicas started together, three consecutive runs: ~~~ ===== run 1 ===== A holds: (none) B holds: (none) C holds: OrderCreated,UserChanged,InvoicePaid ===== run 2 ===== A holds: (none) B holds: OrderCreated,UserChanged,InvoicePaid C holds: (none) ===== run 3 ===== A holds: OrderCreated,UserChanged,InvoicePaid B holds: (none) C holds: (none) ~~~ Winner takes all, and which replica wins is a coin toss. Each process acquires its channels in a tight sequential loop, so whichever one gets there first is still ahead by the time it reaches the last channel. There is no fairness or rebalancing anywhere in the design, and none is attempted. So the accurate mental model for `singleListener` is **one active process and N−1 hot standbys**, not a distributed subscriber pool. That is the right shape for handling change events once. It is the wrong shape for spreading load, and if that is what you are after, adding replicas achieves nothing at all. ## When you want every replica working For that there is `executionLock`, which inverts the arrangement: every process subscribes, and the lock moves from the channel to the individual message. The key is a hash of the notifying backend's PID, the channel and the payload — identical in every listener that receives that notification — so the first one to insert it processes the message and the rest find the row already there. ~~~typescript const pubSub = new PgPubSub({ connectionString, executionLock: true }); ~~~ Three replicas, six notifications: ~~~ A LISTENING at ...187069 <- all three subscribe B LISTENING at ...187671 C LISTENING at ...188277 A HANDLED order=501 at ...192897 A HANDLED order=502 at ...193336 A HANDLED order=503 at ...193797 A HANDLED order=504 at ...194253 B HANDLED order=505 at ...194706 C HANDLED order=506 at ...195152 ~~~ Six notifications, six handlers, no duplicates — and a 4/1/1 split. This is a race, not a balancer: whoever is idle and quickest wins. Over a real workload it evens out somewhat, but it is not a scheduler and should not be sold to yourself as one. Two things to know before turning it on. It is **off by default**, and it costs a write per message per listener rather than one lock per channel, so the database does more work in exchange for the parallelism. And `isActive()` reports `false` in every process even though all of them are subscribed — `activeChannels()` is derived from held *channel* locks, which this mode does not use. Do not health-check on it here. The dedupe key deserves one more look, because it is content-addressed. Two genuinely distinct notifications with byte-identical payloads on the same channel from the same backend collapse into one — the second is treated as already processed. The markers live for an hour (`UNIQUE_LOCK_TTL`), so this is not a narrow window. If your events can legitimately repeat, put something unique in the payload; an id or a timestamp is enough. The README flags this too, and it is the one failure mode here that silently loses data rather than duplicating it. ## The limits worth knowing before you commit The lock solves duplicate handling. It does not upgrade what `LISTEN`/`NOTIFY` fundamentally is, and most of what follows is Postgres, not the library: - **Delivery is at-most-once, with no backlog.** Anything published while no process holds the lock, or while the holder is reconnecting, is gone. There is no replay. If losing an event is unacceptable, the notification should be a hint to go read a table — not the carrier of the fact itself. That is the same trade-off as [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/), arrived at from the other direction. - **Payloads cap at 8000 bytes**, and `notify()` throws a `RangeError` rather than letting Postgres reject it. Another reason to send ids, not rows. - **A connection pooler in transaction mode will break this.** `LISTEN` needs a session that stays put, and the lock's identity is a per-connection `application_name`. Use a session-mode connection, or a direct one. - **The first run needs DDL rights** to bootstrap the lock schema — `CREATE SCHEMA`, `TABLE`, `FUNCTION`, `TRIGGER`. In a locked-down database, provision it ahead of time; a failed bootstrap is logged, and locking silently does not work. - **A wedged holder keeps the lock.** Liveness here means "the connection is open", so a process whose event loop is blocked, or one on the far side of a network partition whose TCP connection has not yet timed out, still counts as alive. Nothing takes over, and the standbys are correct not to. - **Always attach an `'error'` handler.** Connection errors are emitted there; with no listener they go to the logger instead of crashing, which means a silent-but-degraded process is the default if you skip it. None of that argues against the approach. It argues for using it where it fits: a service that reacts to database change events and needs each one handled once, where a lost event during a crash window is survivable because the next notification, or a periodic reconciliation, will catch up. Inside that envelope this is a remarkable amount of reliability for one table and one function, and no new infrastructure at all. If it does not fit — if you need replay, ordering guarantees, or delivery that survives a subscriber being down — then you want a broker, and the honest recommendation is to stop stretching `LISTEN`/`NOTIFY` toward one. ~~~bash npm i --save @imqueue/pg-pubsub ~~~ The [full option list and API reference](https://imqueue.org/api/pg-pubsub/latest/) covers the reconnect settings, the `filtered` option for ignoring your own notifications, and the lock schema name. --- # Graceful shutdown and zero-drop deploys Source: https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/ Published: 2026-07-28 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) Every deploy is a kill signal aimed at a process that is probably busy. The orchestrator sends `SIGTERM` to the old instance, starts a new one, and moves on. The interesting question isn't whether the new version comes up — it's what happened to the message the old one was holding. Usually the answer is that it vanished, and that nothing said so. No request failed, because there was no request to fail: the caller is still waiting on a promise that will never settle. Deploy dashboards stay green. This is why graceful shutdown keeps getting filed as a nice-to-have: it's a reliability problem whose only symptom is *silence*. ## What a consumer has to do on the way out Three steps, and only one of them takes real thought: 1. **Stop taking new work.** 2. **Finish what's already in hand.** 3. **Let go of the connections and exit.** For an HTTP service, step 1 is "stop listening" and step 2 mostly happens for you — the server knows what a request is, so it can count them. A queue consumer has no request object and no connection per unit of work. It has a loop that pops messages, and once a message is popped, the only thing that knows the work exists is your own handler. ## The signals are already taken The first surprise when you go to wire this up in `@imqueue` is that the signals are not free. `IMQService` and `IMQClient` register handlers for `SIGTERM`, `SIGINT`, `SIGHUP` and `SIGQUIT` in their constructors, and the Redis-backed queue adds its own for `SIGTERM`, `SIGINT` and `SIGABRT` when you call `start()` — that last set governed by [`handleSignals`](https://imqueue.org/api/core/latest/core.imqoptions.handlesignals/), on by default. That's a genuinely good default. A service that never thinks about shutdown still releases its watcher lock and closes its Redis connections on the way out, rather than leaving them for a timeout to reap. What it does *not* do is wait for your work. Here is a service whose only method takes three seconds, sent `SIGTERM` 50 ms after the handler started: ~~~ HANDLER START ...548 SIGTERM ...598 (2.5s of work still outstanding) PROCESS EXIT code=0 at ...602 ~~~ Twenty milliseconds. `HANDLER DONE` never printed, no reply was ever published, and the exit code was a clean `0` — the deploy looked fine. It's worth knowing why it's 20 ms and not the one second you might expect. `IMQService`'s handler kicks off `destroy()` without awaiting it and schedules a hard exit at `IMQ_SHUTDOWN_TIMEOUT` (1000 ms by default). The queue's own handler runs immediately after, finds nothing left to release, and calls `process.exit()` on the next microtask. The exact timing doesn't matter much, though, because neither path waits for a handler: there is no ack, no in-flight counter, and no drain hook anywhere in the framework. So the framework does step 3 for you, and steps 1 and 2 are yours. ## Two levers, and the order matters - **`service.stop()`** drops the reader connection and nothing else. Consumption stops; the writer stays up, so a handler that is still finishing can publish its reply. That is exactly step 1. - **`service.destroy()`** tears the transport down — and also deregisters the signal handlers `IMQService` installed, which is what allows a shutdown of your own to reach its end. Getting these backwards is the classic mistake: `destroy()` first closes the very connection your in-flight reply still needs. ## A drain that actually drains Since nothing counts in-flight work for you, count it yourself. One small helper is enough: ~~~typescript const inFlight = new Set>(); /** Register a unit of work so shutdown can wait for it. */ function tracked(work: Promise): Promise { inFlight.add(work); const done = () => { inFlight.delete(work); }; // bookkeeping on a derived promise, so rejection stays the caller's to // handle and this never becomes an unhandled rejection of its own work.then(done, done); return work; } ~~~ Wrap the actual work in it: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; class OrderService extends IMQService { @expose() public async placeOrder(order: Order): Promise { return tracked(this.fulfil(order)); } private async fulfil(order: Order): Promise { // the real work, however long it takes } } const service = new OrderService(); ~~~ Then take the signals over. This has to happen *after* `start()`, because the queue registers its handler during startup: ~~~typescript const GRACE_MS = 25_000; async function shutdown(signal: string): Promise { console.log(`${signal}: draining ${inFlight.size} in flight`); await service.stop(); // stop popping; writer stays up await Promise.race([ // bounded, never open-ended Promise.allSettled([...inFlight]), new Promise(resolve => setTimeout(resolve, GRACE_MS)), ]); await service.destroy(); // now release the transport process.exit(0); } await service.start(); for (const signal of ['SIGTERM', 'SIGINT'] as const) { // @imqueue's own handlers exit without draining — take over from them process.removeAllListeners(signal); process.once(signal, () => { shutdown(signal).catch(err => { console.error('shutdown failed', err); process.exit(1); }); }); } ~~~ `removeAllListeners()` is blunt — it drops any other listener for that signal too, including ones a library you depend on may have installed. Doing it directly after `start()` keeps the blast radius to the framework's own handlers, which is the point. Note also that `handleSignals: false` is not a substitute: it silences the queue's handler, but `IMQService`'s is unconditional. The same service, the same three-second method, the same `SIGTERM` 50 ms in: ~~~ DRAIN START SIGTERM inFlight=1 HANDLER DONE DRAIN END inFlight=0 PROCESS EXIT code=0 ~~~ 2971 ms from signal to exit, and the caller got its `{ receipt: 'ok' }` back. That reply is the whole point: the work didn't just finish, it finished *visibly*. ## The caller's half Zero-drop is two-sided, and the caller's side has a default worth changing. [`callTimeout`](https://imqueue.org/api/rpc/latest/rpc.imqclientoptions.calltimeout/) is unset out of the box, and unset means *wait forever* — so a caller whose service was killed mid-request holds that promise for the life of the process. ~~~typescript const orders = new OrderClient({ callTimeout: 30_000 }); ~~~ Destroying a client doesn't help the calls it already made: pending requests are abandoned rather than rejected, so `callTimeout` is the only thing that ever frees them. Set it on every client, drain or no drain. ## What safe delivery does not cover The natural objection is that guaranteed delivery should make all of this moot. It doesn't, and the shape of the gap is worth being precise about. Safe delivery is off by default in `@imqueue/core` and `@imqueue/rpc`, and on in `@imqueue/job`. What it protects is the **hand-off**: the move from the shared queue into a worker's own holding area, so a process that dies *between* taking a message and starting on it doesn't swallow it. What it does not protect is the part that takes time. A worker killed three seconds into `await chargeCard()` loses that attempt in either mode — the same limit behind [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/) and the deferred work in [scheduled work without a job system](https://imqueue.org/blog/scheduled-work-without-a-job-system/). Draining, in other words, isn't an optimisation layered on top of safe delivery. It's the only mechanism that finishes work already in progress. ## Sizing it for a real deploy - **The grace period has to exceed the drain budget.** Kubernetes sends `SIGTERM`, waits `terminationGracePeriodSeconds` (30 by default), then sends `SIGKILL`. A 25-second drain under a 30-second grace period leaves room; a 60-second drain under it is decoration. - **The signal has to reach PID 1.** A shell wrapper that spawns node as a child usually doesn't forward signals, so nothing you wired up ever runs. The project template gets this right with `exec npm start` — `exec` replaces the shell rather than parenting a process under it. - **Locally, `imq stop` is stricter than your cluster.** It signals the process group, polls for about five seconds, then escalates to `SIGKILL`. Keep local drain budgets inside that, or test shutdown by signalling the process directly. - **Rolling deploys need no traffic choreography.** There is no load balancer to drain and no registry to deregister from: the new instance starts popping, the old one stops, and the queue is the handover point. Start the replacement before the old one finishes draining and the backlog never even grows. ## The limits to be honest about A drain narrows the window; it doesn't close it. `SIGKILL`, an OOM kill, or a lost node still takes in-flight work with it, so *at-least-once* remains the honest guarantee and handlers still need to be idempotent. Work that genuinely takes ten minutes cannot be drained inside any sane grace period — that wants checkpointing and a progress record, so a re-run resumes instead of restarting. And a producer is not exempt: `send()` resolves against a locally generated id before the broker confirms the write, so a process that exits immediately after enqueuing has proven nothing about durability. None of that argues against draining. It argues for treating the drain as what it is: the cheapest large reduction in dropped work available to a queue-based service, and about thirty lines of code. [Getting Started](https://imqueue.org/get-started/) gets you a service to try it on, and the [API reference](https://imqueue.org/api/rpc/latest/rpc.imqservice/) has the exact `stop()` and `destroy()` semantics. --- # Delayed and scheduled work without adding a job system Source: https://imqueue.org/blog/scheduled-work-without-a-job-system/ Published: 2026-07-25 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) It always arrives as one small requirement. Retry that webhook in five minutes. Release expired inventory holds every minute. Send the trial-expiry email in 24 hours. The reflex is to reach for a job system, and the feature you need really is in there — along with a deployment, a data model and a worker tier you didn't ask for. The requirement is not "we need jobs." It is: **run this later.** If your services already talk over a message queue, later is a parameter you're not using yet. ## The work you're deferring is a call you already have A job system asks you to describe the work a second way. The handler itself can be three lines calling the method you already have; it's the description around it that costs you: - **The payload is an untyped mirror of a method signature.** Hand-maintained on both sides, and nothing checks the two against each other when that signature changes. - **The worker is a tier.** A queue to name, an entrypoint to roll out, one more thing to page on. - **The job rows are a second source of truth.** Their own lifecycle, their own retention, their own answer to "did this run?". That isn't an argument against job systems. It's an argument against standing one up to run three delayed operations. In `@imqueue`, deferral isn't a component — it's an argument. The generator appends two optional trailing parameters to every exposed method: call metadata first, then an [`IMQDelay`](https://imqueue.org/api/rpc/latest/rpc.imqdelay/). ~~~typescript import { IMQDelay, IMQMetadata } from '@imqueue/rpc'; // callTimeout has no default, and unset means "wait forever" const notifications = new NotificationClient({ callTimeout: 30_000 }); await notifications.start(); notifications .sendTrialEndingEmail( { userId: '42', plan: 'pro' }, new IMQMetadata({ reason: 'trial-expiry' }), // metadata slot first new IMQDelay(24, 'h'), // the delay is always last ) .then(result => console.log('reminder sent', result)) .catch(err => console.error('reminder failed', err)); ~~~ > **Updated for `@imqueue/rpc` 3.4.0.** When this went out, skipping the metadata > slot with `undefined` failed outright; 3.3.1 made it work for one placeholder, > and 3.4.0 made it the rule — on a delayed call a trailing `undefined` is never > delivered. The sample above works on every version, so it stands unchanged. Fill that metadata slot, or pass `undefined` for it. Both trailing parameters are optional, but they're stripped by identity, not by position — which is why the placeholder took two releases to settle. On 3.3.0 and earlier an explicit `undefined` type-checked and then travelled as a real argument, so a method whose parameters are all required rejected the call with `IMQ_RPC_INVALID_ARGS_COUNT`. From 3.4.0 a trailing `undefined` on a delayed call is dropped, so both spellings work and a skipped optional argument falls back to its default. Dropping the delay straight into the metadata slot is the mirror image — it runs, but it doesn't type-check, on any version. Keep the delay last. `IMQDelay` takes a number plus one of `ms`, `s`, `m`, `h`, `d`. What comes back is a real request/reply promise, pending across the whole delay, so don't `await` one inside a request handler: its resolver lives only in the caller's memory, and a restart loses it while the reply still arrives, to nobody waiting. `callTimeout` is the only backstop, and a delay extends its budget rather than firing early. One layer down it's the same parameter without the sugar: `send()` takes the delay in plain milliseconds, which `@imqueue/job` surfaces as `push(data, { delay })`. The service can't tell either way — an RPC request carries only `from`, `method`, `args` and optional metadata, so validation, decorators and handlers behave exactly as for an immediate call. (`@imqueue/job` keeps the delay in its envelope only to re-arm a retry; the handler gets the job, not the envelope.) ## What happens between now and then Nothing of *your* work runs in your process while it waits: no per-message `setTimeout`, no in-memory timer heap on the delivery path. The only clocks are a maintenance interval per queue instance, the broker's own expiry, and the optional, unref'd `callTimeout` timer. That's what makes this survive a deploy — the caller can exit one second after scheduling a call for next Tuesday. The mechanics belong to the transport. On the Redis-backed core the producer computes a due time — `Date.now() + delay` — and parks the packed message in a sorted set beside the queue, scored by that time, plus a small companion key that expires about then and carries no payload — an alarm clock, nothing more. Its expiry fires a keyspace notification, and one elected watcher runs a single atomic script that moves everything now due onto the ordinary ready list. Failing that, every worker sweeps its own delayed set every `watcherCheckDelay`, 5 seconds by default. ~~~mermaid flowchart LR A["client.update(…, new IMQDelay(24, 'h'))"] --> B["packed message parked in a sorted set, scored Date.now() + delay"] A --> C["companion key expiring about then, carrying no payload"] C -->|"expiry fires a keyspace notification"| D["one elected watcher runs one atomic promote script"] B --> D E["every worker, every watcherCheckDelay (5s)"] -.->|"fallback sweep"| D D --> F["ordinary ready list"] F --> G["any free consumer, behind the existing backlog"] ~~~ `@imqueue` schedules a delayed call in the broker, not in your process — which is why the caller can exit one second after scheduling something for next Tuesday. Where keyspace notifications are unavailable, promotion degrades to the 5-second sweep rather than stopping. From there it's an ordinary message, load-balanced like any other and queued behind the existing backlog. That notification is the primitive covered in [Redis as a message bus](https://imqueue.org/blog/redis-message-bus-patterns/), including the line that catches people out: some managed offerings need `notify-keyspace-events Ex` enabled by hand, and where you can't, promotion quietly degrades to the 5-second sweep. ## Backoff without a retry policy "Retry the webhook in five minutes" points the same primitive back at yourself — and programmable retry timing means a job record again. `@imqueue/job` is a thin envelope over the same `send()` — its own `imq-job` prefix, a queue matched to its handler by `name` alone — so it runs inside a process you already deploy, but the payload is yours. Return a number from a handler and the same job is re-sent after that many milliseconds. ~~~typescript import JobQueue from '@imqueue/job'; type Sync = { orderId: string; attempt: number }; new JobQueue({ name: 'OrderSync' }) .onPop(async job => { try { await warehouse.sync(job.orderId); } catch (err) { console.error('order sync failed', job.orderId, err); job.attempt += 1; if (job.attempt > 5) { await parkForReview(job); // your own dead-letter table return -1; // negative: stop } return 1000 * 2 ** (job.attempt - 1); // 1s, 2s, 4s, 8s, 16s } }) .start() .then(queue => queue.push({ orderId: 'A-1001', attempt: 0 })) .catch(err => console.error('OrderSync failed to start', err)); ~~~ Re-scheduling re-sends the same envelope, so incrementing `job.attempt` in place carries it forward — and that counter is the only attempt cap there is. `parkForReview()` is your own table: there is no declarative `attempts` policy and no dead-letter destination. Catch your own errors rather than leaning on a throw, which re-schedules with the job's *original* delay — so a job pushed without one is dropped instead of retried. Returning `0` isn't a stop either; it re-runs immediately, a hot loop. Stop by returning nothing or a negative number, and note that re-arming publishes: a worker-only queue cannot re-schedule at all. ## Recurrence, honestly There is no cron here. No cron expressions, no repeatable option, no timezone rules, no recurrence rule stored anywhere: a delay is one-shot, at every layer. What you can build is a chain — a tick that schedules its own successor. ~~~typescript import JobQueue from '@imqueue/job'; const EVERY_MINUTE = 60_000; const sweeper = new JobQueue>({ name: 'ExpireHolds' }); sweeper.onPop(async () => { await releaseExpiredHolds(); return EVERY_MINUTE; // re-arm: the next tick is scheduled from here }); await sweeper.start(); // seed exactly one chain, out of band — never on every process boot if (process.env['SEED_EXPIRE_HOLDS']) { sweeper.push({}, { delay: EVERY_MINUTE }); } ~~~ Seeding is the part that bites. Every process that pushes a seed starts its own chain, so ten replicas rolling through a deploy leave ten overlapping sweeps forever. Seed from a one-off task or the scheduler you already run; if it has to happen at boot, gate it on a `SET NX` lease whose TTL spans several periods, retried on an interval so a deploy can't double-seed and a dead chain re-seeds when the lease lapses. > A self-re-arming tick is a chain, not a schedule. If the chain ever breaks, > nothing restarts it — alert on the age of the last completed run. The next fire time is computed *after* the work finishes, so the real period is work plus delay and "every 60s" walks off wall clock. The re-arm is an ordinary send inside an async handler: if it fails, the recurrence ends and nothing says so. A chain of one-shot delays is not a schedule — if the requirement is calendar-shaped, put it where calendars already live and have it trigger an ordinary call. ## The trade-offs to know before you ship it Skipping the job tier isn't free either — it moves the costs somewhere else: - **It's "no earlier than," not "exactly at."** The delay is expressed in milliseconds; the delivery isn't accurate to one. A 1500 ms delay arrived at a consumer after 1532 ms on the notification path — and that's one hop, before any reply travels back; without notifications you inherit the poll interval instead. The due time comes from the *sending* process's clock and is compared against the *sweeping* one, so skew shifts it either way. Pass whole milliseconds, too: a fractional delay fails to set the alarm key and waits for the next sweep, and an `IMQDelay` carrying a unit string it doesn't recognise means no delay at all. - **Enqueue is fire-and-forget.** `send()` resolves with a locally generated id before Redis confirms the write, and `push()` returns synchronously — awaiting either proves nothing about durability. `send()` takes an error handler for exactly that; `push()` takes none, so a failed enqueue only reaches the queue's logger. The delayed set is ordinary data too, so persistence is load-bearing for anything scheduled far out. - **There is no handle for one message.** The id you get back isn't one: nothing cancels, reschedules or inspects a single scheduled message, and the only removal path is wholesale — clearing a queue's data wipes every scheduled message for it, other producers' included. If an action can be revoked, gate the handler with a state check. - **Something has to be alive to sweep.** A waiting message needs neither its sender nor a consumer, and any live instance on the prefix elects itself watcher when none exists — but the polling fallback runs only on workers, so with notifications unavailable and no worker for that queue, nothing promotes it. - **An outage ends in a burst.** Everything due while you were dark is promoted in one pass, with no rate limit, no "skip missed runs" policy and no concurrency cap to meter the result — and [a backlog is still latency](https://imqueue.org/blog/backpressure-nodejs-services/). Guarantees are inherited, and narrower than they sound. Safe delivery is off by default in `@imqueue/core` and `@imqueue/rpc` — `@imqueue/job` turns it on for you — and with it off, a promoted message a dying consumer was holding is gone. With it on, the lease covers only the hand-off, so a process killed three seconds into `await warehouse.sync()` still loses that attempt. Same trade-off as [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/) — `safeDeliveryTtl` on a queue or service, `safeLockTtl` through `@imqueue/job` — and *at-least-once* is still the honest guarantee, so write long deferred work to be re-runnable, with its own progress record. ## When a job system is the right call Deferral-as-a-parameter fits when the work is a call you already expose and "roughly then, at least once" is acceptable. Three things say otherwise: - **Operators need to steer the queue.** Priorities, attempt caps, dead-lettering, pause and resume, per-job status, rate limiting, a concurrency cap, a dashboard — none of that exists here. The feature-by-feature split is in [`@imqueue/job` vs BullMQ](https://imqueue.org/blog/imqueue-vs-bullmq/). - **The schedule is declarative.** Cron strings, timezones, "every 5 minutes" as configuration rather than as a self-re-arming chain. That's a scheduler's job, and [the alternatives guide](https://imqueue.org/blog/bullmq-alternatives/) maps the libraries that do it. - **It's really a workflow.** Multi-step, resumable, with history and compensation. That's an orchestration engine like Temporal — a different category, not a bigger delay. If none of those apply, you probably don't need a job system for this — you need an extra argument or two on a call you already make. The [`IMQDelay` reference](https://imqueue.org/api/rpc/latest/rpc.imqdelay/) has the exact signature, and [Getting Started](https://imqueue.org/get-started/) gets you a service to schedule against in a couple of minutes. If an AI assistant writes this code for you, point it at the machine-readable version of these recipes: [/agents/delayed-scheduled-work/](https://imqueue.org/agents/delayed-scheduled-work/). --- # Auto-scaling Redis broker: with and without broadcast Source: https://imqueue.org/blog/horizontally-scalable-redis-broker/ Published: 2026-07-24 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) **A horizontally scalable Redis broker** is the missing half of scaling a message-driven system. Adding service instances is easy — with [`@imqueue`](https://imqueue.org/get-started/) two copies of a service just read the same queue — but all of that traffic still funnels through one Redis. At some point that single broker is both your throughput ceiling and your single point of failure. @imqueue's answer is not Redis Cluster and not a managed proxy: it's a **fleet of plain, independent Redis instances** that services discover at runtime, with producers spreading load across them and consumers draining all of them at once. And because discovery runs continuously, the fleet doesn't just scale — it **auto-scales**: add a broker and every service folds it into rotation within a second; remove one and traffic re-routes just as fast, no config pushes, no redeploys. The only part that changes between environments is *how brokers announce themselves* — and that's what the two recipes below are about. > **TL;DR** — Load a tiny announcer module into every Redis broker and the > broker layer becomes horizontally **auto-scaling**: services discover the > fleet over UDP as brokers come and go. On networks that deliver limited > broadcast (bare metal, LANs, Docker bridge) use > [redis-broker-promoter](https://github.com/imqueue/redis-broker-promoter), > which shouts to `255.255.255.255`. On networks that drop broadcast — GCP > VPCs, most Kubernetes overlays — use > [redis-broker-unicaster](https://github.com/imqueue/redis-broker-unicaster), > which asks the Kubernetes API for pod IPs and unicasts the same datagram to > each of them. The service side is identical either way: > `clusterManagers: [new UDPClusterManager()]`. ## How @imqueue clusters the broker Clustering lives on the client side, in [`ClusteredRedisQueue`](https://imqueue.org/api/core/latest/core.clusteredredisqueue/). You never instantiate it directly — the factory swaps it in whenever the options mention a cluster, so services and generated clients get it with zero code changes: ~~~typescript import { IMQServiceOptions, UDPClusterManager } from '@imqueue/rpc'; export const serviceOptions: Partial = { // dynamic discovery (the subject of this post): clusterManagers: [new UDPClusterManager()], // …or, instead of a manager, a static fleet known up front: // cluster: [ // { host: 'redis-1', port: 6379 }, // { host: 'redis-2', port: 6379 }, // ], }; ~~~ The model is deliberately simple. There is no sharding and no consistent hashing: every broker hosts an identically-named queue, producers pick a broker per message in **health-aware round-robin** (a broker whose connection is known to be down is skipped), and consumers run a blocking read against **all brokers concurrently**. Throughput scales with the number of brokers; losing one broker just narrows the rotation. The brokers themselves are stock standalone Redis — they never talk to each other, don't replicate, and don't even know they are part of a fleet. Neither announcer module registers a single Redis command. ## The discovery protocol both recipes share Each broker loads a small C module that periodically emits a one-line, tab-separated UDP datagram: ~~~ imq-broker 2cc7c345-3569-44bb-b57a-b72d729d7012 up 10.0.4.12:6379 1 imq-broker 2cc7c345-3569-44bb-b57a-b72d729d7012 down 10.0.4.12:6379 ~~~ That's `name`, a per-process GUID, `up`/`down`, the advertised `host:port`, and — for `up` — the announce interval in seconds. On the service side, [`UDPClusterManager`](https://imqueue.org/api/core/latest/core.udpclustermanager/) listens on UDP port `63000` (its default) in a worker thread and translates datagrams into cluster changes: - **`up`** — add the broker (deduplicated by GUID or address) and re-arm its liveness timer. - **`down`** — sent on graceful shutdown; the broker is removed immediately. - **silence** — a broker that misses heartbeats for `interval × 1000 + 5000 + 1` ms (about six seconds at the default 1-second interval) is evicted, which covers crashes and network partitions. Both modules read the same environment variables — `REDIS_BROADCAST_NAME` (default `imq-broker`), `REDIS_BROADCAST_INTERVAL` (seconds, default `1`) — and emit byte-identical messages. They differ **only in how the datagram travels**, which is exactly why the client side doesn't care which one you run. ## Recipe 1: networks that deliver broadcast — redis-broker-promoter If your brokers and services share an L2 segment — bare-metal boxes, on-prem VMs, a Docker bridge network, your laptop — the simplest transport is UDP *limited broadcast*: one `sendto()` to `255.255.255.255` reaches every host on the segment, no inventory required. That's all [redis-broker-promoter](https://github.com/imqueue/redis-broker-promoter) does: ~~~bash git clone https://github.com/imqueue/redis-broker-promoter.git cd redis-broker-promoter && make # needs libuuid REDIS_BROADCAST_NAME=imq-broker \ REDIS_BROADCAST_INTERVAL=1 \ redis-server --port 6379 --loadmodule $PWD/promoter.so ~~~ On load the module spawns one announcer thread per network interface allowed by your Redis `bind` configuration (`0.0.0.0` means all of them) and broadcasts `up` every interval to `255.255.255.255:63000` (`REDIS_BROADCAST_PORT` configurable). On shutdown it broadcasts `down`. Scaling out is now an operational no-op: start another `redis-server` with the module loaded, and every service adds it to the rotation within roughly one interval. Stop it, and the fleet shrinks just as automatically. That is horizontal **auto-scaling** of the broker layer — hook broker instances to whatever triggers your scaling decisions and the services follow along; nothing else needs restarting or reconfiguring. A useful side effect of limited broadcast: routers never forward `255.255.255.255`, so announcements are confined to the local segment. That's the constraint that breaks this recipe in the cloud — and a small security property everywhere else. ## Recipe 2: networks that block broadcast — redis-broker-unicaster (Kubernetes on GCP and other clouds) Cloud VPCs are software-defined networks, and most of them — GCP explicitly — do not deliver broadcast or multicast at all. A datagram to `255.255.255.255` in a GCP VPC or across a typical Kubernetes overlay simply vanishes, and the promoter recipe goes silent. [redis-broker-unicaster](https://github.com/imqueue/redis-broker-unicaster) *emulates* broadcast instead of relying on it. Every interval it asks the Kubernetes API for the pods in its namespace and sends the very same datagram as plain UDP **unicast to each pod IP** at port `63000`. Pods that aren't listening drop it; pods running `UDPClusterManager` get exactly what they would have gotten from a broadcast. Newly scheduled service pods start receiving announcements within one interval — no service registry, no headless-service DNS, no multicast anywhere. Scale the broker Deployment up or down — by hand or with an autoscaler — and the fleet follows: the same horizontal auto-scaling as the broadcast recipe, minus the broadcast. One boundary to be clear about: this recipe lives *inside Kubernetes* — the module authenticates with the pod's mounted service-account token and talks to `kubernetes.default.svc`. On cloud VMs outside Kubernetes, reach for the static `cluster` list instead (last row of the table below). The broker's service account needs permission to list pods: ~~~bash git clone https://github.com/imqueue/redis-broker-unicaster.git cd redis-broker-unicaster && make # needs libuuid, libcurl, json-c # inside the broker pod — the module needs the mounted service-account token DEPLOYMENT_ENV=production \ SELECTED_INTERFACES=10. \ redis-server --port 6379 --loadmodule $PWD/unicaster.so ~~~ - **`DEPLOYMENT_ENV`** — the Kubernetes namespace to enumerate pods in (and therefore the blast radius of the announcements). Announcements reach only this namespace, so brokers and every service or client that should discover them must run in the same one. - **`SELECTED_INTERFACES`** — comma-separated IP prefixes (e.g. `10.,192.168.`) selecting which local interfaces announce themselves; unset means all of them, loopback included, so set it in real deployments. - The RBAC side is a `Role` with `list` on `pods` plus a `RoleBinding` to the broker pod's service account. - In the current implementation the announce destination port is fixed at `63000`, so leave `UDPClusterManagerOptions.port` at its default on the service side. ## The service side is the same in both recipes Whatever transport the announcements take, services and clients configure one thing. A pattern that has served well in production keeps a static fallback one environment variable away: ~~~typescript const DISABLE_CLUSTER_MANAGER = !!+(process.env.DISABLE_CLUSTER_MANAGER || 0); const cluster = (process.env.REDIS_CLUSTER || 'localhost:6379') .split(/\s*,\s*/) .map(cfg => { const [host, port] = cfg.split(/\s*:\s*/); return { host, port: +port }; }); Object.assign(serviceOptions, DISABLE_CLUSTER_MANAGER ? { cluster } // static list : { clusterManagers: [new UDPClusterManager()] } // discovery ); ~~~ One rule matters: **apply the same cluster options to every service and every client**. Requests and replies flow through the whole fleet, so a client pinned to a single broker will miss responses that round-robin landed elsewhere. ## Life of the fleet - **A broker joins.** Discovered within about one announce interval; @imqueue starts its queue, replays subscriptions, and folds it into the rotation. If a service sends before *any* broker is known (cold start), the send waits for the first discovery for up to 30 seconds (`IMQ_SEND_INIT_TIMEOUT`) instead of failing. - **A broker leaves gracefully.** The module's shutdown hook emits `down` and removal is immediate. That's reliable at the default 1-second announce interval; at longer intervals shutdown can outrun the announcer thread, in which case removal falls back to heartbeat eviction. - **A broker crashes.** No `down` arrives; the missed-heartbeat eviction removes it a few seconds later. Messages already queued on it stay in its Redis (subject to your persistence settings) and become consumable again when it returns — the fleet keeps flowing through the remaining brokers meanwhile. - **Auth.** Give every broker the same credentials (one shared ACL file works well), because any service may connect to any discovered broker. - **Security.** The datagrams are plain, unauthenticated UDP — anyone who can reach the port can inject or evict brokers. Broadcast confines itself to the L2 segment; for the unicaster, keep UDP `63000` cluster-internal with a NetworkPolicy and treat the namespace boundary as the trust boundary. ## Picking a recipe | Environment | Recipe | | --- | --- | | Bare metal, on-prem VMs, one L2 segment | promoter (broadcast) | | Docker bridge network, local development | promoter (broadcast) | | Kubernetes — on GCP or any cloud VPC | unicaster (K8s-API unicast) | | Cloud VMs outside Kubernetes, fixed topology | static `cluster` list, no modules | The broker fleet is the piece that turns "we can scale the services" into "the whole system auto-scales". If you're starting fresh, the [getting-started guide](https://imqueue.org/get-started/) gets a service and client running in minutes; for how the discovery mindset extends to services themselves, see [do Node.js backends need service discovery?](https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/) and [load balancing without a load balancer](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/). --- # One isolated imq CLI home per @imqueue project Source: https://imqueue.org/blog/isolated-imq-cli-environments/ Published: 2026-07-23 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) **Managing multiple @imqueue projects on one machine** works fine — right up until it doesn't. The `imq` CLI keeps everything it owns under a single home directory, `~/.imq`: your global configuration, the templates cache, and the runtime state behind `imq ctl` and `imq log`. With one project you'll never notice. With two — a work fleet and a side project, or two clients with different VCS orgs and tokens — that shared home becomes a source of quiet collisions. > **TL;DR** — set `IMQ_CLI_HOME` to give each project its own CLI home: > `~/.imq` becomes `$IMQ_CLI_HOME/.imq`, and *everything* moves with it — > `config.json`, the templates cache and the `var/` pid/log store used by > `imq ctl` and `imq log`. Wire it up once with direnv or a two-line shell > function, and every fleet gets its own config, tokens, template branch, > logs and pid registry. ## Where one shared ~/.imq bites The CLI creates exactly four things under its home ([full table](https://imqueue.org/cli/installation/#files-the-cli-creates)): - **`config.json`** — the global configuration: providers, namespaces, auth tokens. Written with `0600` permissions because it may hold secrets. - **`templates/`** — a cached git clone of the templates repository, pinned to the `templatesRef` branch from your config. - **`custom-templates/`** — cached custom templates. - **`var/`** — fleet runtime state: one `.log` per service and a single `.pids` file of `service:pid` records. Share that home across two projects and here's what actually goes wrong: - **One global config.** `vcs.namespace`, provider choices and auth tokens are machine-wide. Working for two clients means either flipping values back and forth with `imq config set` or leaking one client's defaults into the other's scaffolds. - **One pid registry, logs keyed by service name.** Logs are named after the service directory's basename and truncated on every start. Two fleets that both contain a `user-service` overwrite each other's pid records and wipe each other's logs. - **The stop fallback.** Run `imq ctl stop` from a directory where no services are discoverable — and without `-s` — and it falls back to stopping *every* pid tracked in that home. With a shared home, that includes the other project's fleet. - **One templates working copy.** The cache is a single git clone that gets `fetch` + `checkout` on use. Two projects pinning different `templatesRef` branches will re-checkout each other's ref on every scaffold. ## One variable moves everything `IMQ_CLI_HOME` replaces the *base* the CLI treats as your home directory — it doesn't replace `~/.imq` itself, it replaces the `~`: ~~~bash export IMQ_CLI_HOME=/some/where # ~/.imq becomes /some/where/.imq ~~~ Everything the CLI touches flows through that one resolution — config, templates, custom templates, pids and logs. Nothing in the CLI hardcodes your real home directory, and this isn't an accident of implementation: the CLI's own test suite runs every end-to-end test inside a fresh `IMQ_CLI_HOME`, so the isolation path is exercised on every release. Two properties make it pleasant to use: - **No setup ceremony.** A fresh home doesn't need `mkdir` — the first `imq config init` or `imq config set` creates the directory chain itself. - **It's the complete list.** Per-service settings live in each repo's committed [`.imqrc.json`](https://imqueue.org/cli/configuration/#per-service-overrides-imqrcjson), so they travel with the project no matter which home is active. The usual precedence chain — CLI flag → `.imqrc.json` → global config → prompt → default — still applies; only the "global config" layer moves. That split is the mental model: **things the project owns stay in the repo (`.imqrc.json`); things you or a client own move with the home (tokens, namespaces, template branches, runtime state).** ## The recipes ### 1. A home per project, automatically, with direnv Drop an `.envrc` at the root of each project directory: ~~~bash # ~/work/acme/.envrc export IMQ_CLI_HOME="$PWD/.imq-home" export IMQ_NO_UPDATE_CHECK=1 ~~~ Run `direnv allow` once. Every shell that enters `~/work/acme` — including your editor's terminal — now talks to that project's private CLI home, and leaves it behind on `cd ..`. Add `.imq-home/` to the project's `.gitignore`: its `config.json` may hold tokens. ### 2. Named fleets with a shell function If you prefer explicit switching over directory magic: ~~~bash # ~/.zshrc or ~/.bashrc imqf() { # usage: imqf local fleet="$1"; shift IMQ_CLI_HOME="$HOME/.imq-fleets/$fleet" IMQ_NO_UPDATE_CHECK=1 imq "$@" } ~~~ ~~~bash imqf acme config init imqf acme ctl start -p ~/work/acme/services -c imqf globex ctl status -p ~/work/globex/services ~~~ The fleet name is right there in your shell history, and tab completion for `imq` keeps working since the function just prefixes the environment. ### 3. Per-fleet configuration: clients, tokens, template branches Each home carries its own `config.json`, so per-client setups stop being a juggling act: ~~~bash imqf acme config set vcs.provider github imqf acme config set vcs.namespace acme-org imqf acme config set vcs.auth.token "$ACME_GITHUB_TOKEN" imqf acme config set templatesRef master imqf globex config set vcs.provider gitlab imqf globex config set vcs.namespace globex imqf globex config set templatesRef v4-stable ~~~ Every `imq service create` under the acme home now scaffolds into the right org with the right token — and because each home has its **own templates clone**, the two `templatesRef` branches no longer fight over one working copy. Point a fleet at a forked templates repo entirely with `IMQ_TEMPLATES_REPO`. ### 4. Disposable sandboxes (and CI) Want to try a risky config change, test a custom template, or demo the CLI without touching your real setup? ~~~bash IMQ_CLI_HOME="$(mktemp -d)" imq config init ~~~ Everything lands in the temp directory; delete it and the experiment never happened. The same trick is the clean way to run the CLI in CI — give the job a workspace-local home and set `IMQ_NO_UPDATE_CHECK=1` (the self-update check also auto-skips when there's no TTY). ### 5. Two fleets running side by side With separate homes, parallel fleets stop being scary: ~~~bash # terminal 1 — the acme fleet imqf acme ctl start -p ~/work/acme/services -c imqf acme log # terminal 2 — the globex fleet imqf globex ctl start -p ~/work/globex/services -c imqf globex log ~~~ Both fleets can contain a `user-service`; their logs and pid records live in different `var/` directories, so nothing is overwritten or truncated. `imqf acme ctl stop` can only ever touch acme's pids — the stop fallback is now scoped to one project, which turns it from a hazard into a feature: "stop everything in *this* fleet". One boundary to respect: `IMQ_CLI_HOME` isolates the *CLI's* state, not your infrastructure. If both fleets connect to the same Redis and share a service name, those workers join the same queue — that's @imqueue's [load balancing](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/) doing its job across projects, which is not what you want here. Give each fleet its own Redis port or database number. ## Gotchas worth knowing - **Set it before `imq` starts.** The CLI resolves its home once at startup; exporting `IMQ_CLI_HOME` has no effect on an already-running command. - **`~/.ssh` stays put.** SSH keys are deliberately *not* relocated — a sandboxed environment still uses your real keys for git transport. Override separately with `IMQ_SSH_DIR` if you need to. - **Install shell completions from your normal environment.** `imq completions on` resolves `~/.zshrc` / `~/.bashrc` against the CLI home, so with `IMQ_CLI_HOME` set it would write the completion block into `$IMQ_CLI_HOME/.zshrc` instead of your real rc file. Run it once without the variable. - **The self-update check is global anyway.** There's one `npm i -g @imqueue/cli` shared by every environment — which is why the recipes above bake in `IMQ_NO_UPDATE_CHECK=1`. ## Frequently asked questions about isolated imq CLI homes ### Do I need IMQ_CLI_HOME if I only have one project? No. The defaults are exactly right for a single project — reach for isolation when the second project appears, or when you want a scratch environment. ### Does IMQ_CLI_HOME change how my services run? No. Only the CLI reads it. Your services take their own configuration and environment as always — what moves is the CLI's bookkeeping *about* them: config, templates, logs and pid records. ### Where should per-client tokens live? In that client's own home — each `config.json` is written `0600` in its own directory. For one-off runs you can still pass `-T ` on the command line instead. Never commit an `.imq-home` directory. ### Can two fleets use different template versions? Yes — that's one of the main wins. Each home keeps its own clone of the templates repository, so `templatesRef` (and even `IMQ_TEMPLATES_REPO`) can differ per fleet without interference. --- The full environment-variable table lives in the CLI User Guide's [configuration reference](https://imqueue.org/cli/configuration/#environment-variable-reference), and fleet operations are covered in [Managing Local Services](https://imqueue.org/cli/managing-local-services/). If your AI assistant manages your fleets for you, point it at the machine-readable version of these recipes: [/agents/isolated-imq-environments/](https://imqueue.org/agents/isolated-imq-environments/). New to @imqueue? Start with [Get Started](https://imqueue.org/get-started/) — your first service is about five minutes away. --- # RPC over Redis in Node.js: patterns and pitfalls Source: https://imqueue.org/blog/rpc-over-redis-nodejs/ Published: 2026-07-23 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) **RPC over Redis** means making one service call a method on another by sending the request through Redis and getting the reply back the same way — instead of opening an HTTP connection or a gRPC channel between them. Redis is already in most Node.js stacks, it's fast, and using it as the transport removes a surprising amount of moving parts: no per-service HTTP server to expose, no load balancer in front, no service registry to look anybody up. This post explains how the pattern works, the problems you have to solve to make it production-grade, and how [`@imqueue`](https://imqueue.org/get-started/) implements all of that with fully-typed clients. > **TL;DR** — RPC over Redis is request/reply messaging: the caller drops a request > on the callee's Redis-backed queue and waits for a correlated response on its own. > It's simpler to operate than HTTP-between-services, but you have to handle > correlation, timeouts, at-least-once delivery, serialization and backpressure > yourself. The maintained, typed way to get it in Node.js/TypeScript is > `@imqueue/rpc`, which generates the client for you from the running service. ## The pattern, concretely At its core the pattern is four steps: 1. The **caller** serializes a request — target method, arguments, a unique correlation ID, and the name of the queue it wants the answer on — and pushes it onto the **callee's** queue in Redis. 2. The **callee** is blocked waiting on that queue. It pops the request, runs the method, and pushes the result onto the **reply queue** named in the request. 3. The caller, blocked on its own reply queue, receives the message, matches the correlation ID to the pending call, and resolves the promise. 4. Both sides go back to waiting. Redis gives you the two primitives this needs: a place to put messages (lists consumed with a blocking `BRPOP`, or streams) and low latency. There's no direct connection between the two services at all — Redis is the rendezvous point, which is exactly why you stop needing service discovery and a load balancer. Add a second instance of the callee and it simply reads from the same queue; Redis distributes the work. ## The pitfalls (what "just use Redis" leaves out) The four-step sketch is easy to prototype and deceptively hard to make reliable. Every one of these is a problem you own the moment you hand-roll it: - **Correlation.** Many calls are in flight at once over one reply queue. Every request needs a unique ID and the caller needs a map of ID → pending promise, or responses get delivered to the wrong caller. - **Timeouts.** If the callee is down or throws before replying, the response never comes. Without a per-call timeout the caller's promise hangs forever and the pending-call map leaks memory. - **Delivery semantics.** A reliable queue gives you *at-least-once* delivery — a message can be redelivered after a crash. That means your handlers should be idempotent, and "exactly once" is something you engineer, not something Redis hands you. - **Serialization.** `JSON.stringify` silently drops `Date`, `Map`, `Set`, `BigInt`, `undefined` and typed arrays. Round-tripping rich objects needs a real serializer, or subtle data corruption creeps in. - **Backpressure.** If callers produce faster than callees consume, the queue grows without bound and latency climbs. You need to watch queue depth and push back. - **Redis itself.** Redis becomes shared infrastructure on the hot path. In production that means clustering/failover, not a single node — the transport is only as available as the Redis behind it. - **Types.** Nothing above says anything about *types*. A raw Redis message is an opaque blob; the caller has no idea what shape the arguments or the result should be. This is where hand-rolled RPC hurts most over time. ## Why the existing packages stalled Search npm for "redis rpc" and you'll find a scatter of small libraries — `node-redis-rpc`, `redis-rpc`, `rpc-redis` — most last published five to ten years ago, none TypeScript-first, and none solving the typing problem. They prove the pattern is sound and useful, but they were built for a callback-era Node.js and stopped being maintained. If you adopt one today you inherit the correlation and timeout machinery but still hand-write an untyped client for every service, and you're on your own for the rest of the list above. ## Doing it typed: @imqueue [`@imqueue`](https://imqueue.org/get-started/) is a maintained implementation of this exact pattern, built for TypeScript. Two pieces do the work: - [`@imqueue/core`](https://imqueue.org/api/core/latest/) is the reliable message queue over Redis (`ClusteredRedisQueue`) — it owns delivery, blocking reads, reconnection and the serialization that plain JSON gets wrong. - [`@imqueue/rpc`](https://imqueue.org/api/rpc/latest/) is the RPC layer on top. You write a service as a class and mark the callable methods with `@expose()`: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; class UserService extends IMQService { /** * Returns a user by id. * * @param {string} id * @return {Promise} */ @expose() public async get(id: string): Promise { return this.db.users.find(id); } } ~~~ The service is **self-describing**: it publishes its method signatures (JSDoc is the type source), so the caller doesn't need a hand-written client. You generate the real one from the running service: ~~~bash imq client generate UserService ~~~ and call it like a local, fully-typed object — the correlation, reply routing and timeouts are handled for you: ~~~typescript const users = new UserClient(); await users.start(); const user = await users.get('42'); // typed: User, no client boilerplate ~~~ Because the client is generated from the live service rather than hand-maintained, the types can't drift out of sync with the implementation — the failure mode that makes hand-rolled RPC rot. Everything on the pitfalls list (correlation, timeouts, at-least-once delivery, serialization, backpressure handling) lives in the library, not in your service code. ## When this is the right call — and when it isn't RPC over Redis is a good fit when your services already share a Redis, when you want internal calls without standing up and load-balancing HTTP endpoints, and when strong typing across service boundaries matters. It is **not** a workflow engine: if you need durable, resumable, long-running orchestration with history and compensation, a system like Temporal is a different tool. And it does add Redis to your critical path — worth it when Redis is already there, a cost to weigh when it isn't. If that fit sounds right, the [getting-started guide](https://imqueue.org/get-started/) has a working two-service example running in a couple of minutes, and the [throughput benchmark](https://imqueue.org/blog/benchmarking-imqueue-throughput/) covers the numbers and a reproducible harness. --- # BullMQ alternatives for Node.js: an honest 2026 guide Source: https://imqueue.org/blog/bullmq-alternatives/ Published: 2026-07-22 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) [BullMQ](https://docs.bullmq.io/) is the name most Node.js teams reach for when they need background jobs on Redis — it's mature, feature-rich, and by far the most downloaded option (~500K weekly). For a lot of projects it's the right answer, and this guide isn't here to talk you out of it. But "most popular" and "best fit for *your* stack" aren't the same thing. Maybe you don't run Redis. Maybe you want a smaller surface than BullMQ's full job-lifecycle machinery. Maybe you already have a service framework and want the queue to be part of it. This is an even-handed map of the realistic alternatives in 2026, what each is genuinely good at, and how to choose. > **Disclosure:** I maintain `@imqueue`, which includes a job queue ([`@imqueue/job`](https://github.com/imqueue/job)). It's on this list, and I've tried hard to place it honestly — as one small, opinionated option among several, not the "winner." Where BullMQ or another tool is the better fit, I say so. ## When you'd look for a BullMQ alternative BullMQ is an excellent default. You'd reasonably shop around when: - **You don't want to run Redis.** BullMQ requires it. If your system of record is Postgres or MongoDB, adding Redis just for jobs is real operational surface (another thing to provision, monitor, back up, and reason about during failures). - **You want a smaller footprint.** BullMQ's feature surface — flows, rate limiting, repeatable/cron jobs, priorities — is a strength when you need it and weight when you don't. - **Licensing matters to you.** BullMQ is MIT. Some alternatives (including `@imqueue`) are copyleft or dual-licensed — that can be a plus or a minus depending on your project. - **The queue should be part of a larger framework**, sharing a client, serialization, and conventions with the rest of your services. - **You want a fully managed queue** and are willing to trade library-level control for not operating infrastructure at all. ## The landscape at a glance | Option | Datastore | Best for | License | |---|---|---|---| | **BullMQ** | Redis | Feature-rich job processing: flows, rate limits, cron, priorities | MIT | | **Bee-Queue** | Redis | Simple, high-throughput, short-lived jobs | MIT | | **pg-boss** | PostgreSQL | Teams already on Postgres who want ACID guarantees, no Redis | MIT | | **Agenda** | MongoDB | Cron-style recurring scheduling on an existing Mongo stack | MIT | | **@imqueue/job** | Redis | Minimal, safe-by-default jobs inside a cohesive RPC framework | GPL‑3.0 / commercial | | **Cloud queues** (SQS, Cloud Tasks) | Managed | Zero infra ops, cloud-native pipelines | Proprietary (usage-based) | | **Bull** (v3) | Redis | Existing apps already on it | MIT | *(Feature and maintenance details reflect each project's documented behavior at the time of writing — always check the project's own repo for the current picture.)* ## The alternatives, one by one ### Bee-Queue — the lightweight Redis option [Bee-Queue](https://github.com/bee-queue/bee-queue) is a Redis-backed queue built deliberately around simplicity and speed, with a much smaller feature set than BullMQ. It shines for **large volumes of short, near-real-time jobs** where you don't need flows, cron, or a big lifecycle API. If BullMQ feels like more than you need and you're staying on Redis, Bee-Queue is the classic "just a fast queue" pick. **Trade-off:** you give up the breadth — no flows, limited scheduling/lifecycle features — in exchange for a small, fast core. ### pg-boss — a job queue *inside* Postgres [pg-boss](https://github.com/timgit/pg-boss) is the standout if you're **already on PostgreSQL and don't want to add Redis**. It implements the queue using Postgres's `SKIP LOCKED` — a feature designed precisely for this — giving you safe concurrent job pickup with the ACID guarantees of your existing database. Jobs commit in the same transactional world as your data, and it's actively maintained. **Trade-off:** Postgres-backed queues top out at lower throughput than a Redis queue under very heavy load, and you're leaning on your primary database for queue traffic. For most application workloads that's a fine bargain; for extreme fan-out it may not be. ### Agenda — MongoDB-based scheduling [Agenda](https://github.com/agenda/agenda) uses MongoDB and is oriented toward **cron-like recurring scheduling**. If your stack already runs Mongo and your need is "run this job every night / every 5 minutes" more than "process a firehose of jobs," Agenda fits naturally without introducing a new datastore. **Trade-off:** it's a scheduler first; it isn't aiming at the high-throughput, rich-lifecycle territory BullMQ occupies. ### @imqueue/job — minimal and safe-by-default [`@imqueue/job`](https://github.com/imqueue/job) is the Redis-backed job queue inside the `@imqueue` framework. Its point of difference is being **small and safe by default**: guaranteed delivery is on out of the box, so a job a dying worker was holding is re-queued for another worker after a lock TTL — the lease covers the hand-off rather than your handler, making *at-least-once* the real guarantee — with delayed/scheduled jobs to millisecond granularity and programmable retry/backoff. It's TypeScript-first and has a single dependency. It makes most sense when you **already use `@imqueue` for service-to-service RPC** (the job queue shares the same core, serialization and conventions), or when you specifically want a tiny queue where "don't lose jobs" is the default rather than something you configure. **Trade-off:** it is *not* trying to match BullMQ's feature surface. If you need declarative retry policies with automatic dead-lettering, priorities, rate limiting, cron-style repeatable jobs, or flows/parent-child dependencies, BullMQ is the stronger tool and `@imqueue/job` doesn't compete there. It's also GPL‑3.0 (with a commercial license available) rather than MIT — [worth understanding](https://imqueue.org/license/) if you ship closed-source. There's a full, honest head-to-head in [**@imqueue/job vs BullMQ**](https://imqueue.org/blog/imqueue-vs-bullmq/). ### Cloud / managed queues — no infra to run If you'd rather not operate a queue at all, managed services like **AWS SQS** or **Google Cloud Tasks** move the durability, scaling and availability problem to the provider. Great for cloud-native pipelines and teams that want to minimize ops. **Trade-off:** you trade library-level control and local ergonomics for a network hop and vendor coupling, and you work within the provider's model (visibility timeouts, delivery semantics, quotas) rather than an in-process API. ### Bull (v3) — the predecessor [Bull](https://github.com/OptimalBits/bull) is BullMQ's older sibling. It isn't deprecated and still powers plenty of production systems, but **BullMQ is the recommended path for new projects** — it's the actively evolved successor with the modern API. If you're already on Bull and it works, there's no emergency; if you're starting fresh, start on BullMQ. > **Skip:** **Kue** is officially deprecated and unmaintained — don't reach for it in new projects. You'll still see it in old tutorials; use one of the options above instead. ## How to choose A quick decision guide: - **Already on Redis, need the full feature set (flows, rate limiting, cron, priorities)?** → **BullMQ**. It's the default for a reason. - **On Redis but want something small and fast for short jobs?** → **Bee-Queue**. - **Primary datastore is Postgres and you'd rather not add Redis?** → **pg-boss**. - **On MongoDB and mostly need recurring/cron scheduling?** → **Agenda**. - **Want zero queue infrastructure to operate?** → a **cloud queue** (SQS / Cloud Tasks). - **Already building services with `@imqueue`, or you want a minimal queue where at-least-once delivery is the default?** → **[`@imqueue/job`](https://imqueue.org/get-started/)**. The honest summary: **BullMQ remains the safe, capable default on Redis.** The alternatives win when a *specific* constraint — your datastore, your appetite for features, your ops budget, or your framework — points elsewhere. ## Frequently asked questions about BullMQ alternatives ### Is BullMQ still the best Node.js job queue in 2026? For a feature-rich, Redis-backed queue, BullMQ is still the most capable and most widely used option. "Best" depends on your constraints — if you don't run Redis or want a smaller surface, an alternative may fit better. ### What's the best BullMQ alternative if I don't use Redis? pg-boss (PostgreSQL) if you're on Postgres, or Agenda (MongoDB) if you're on Mongo. Both let you avoid introducing Redis just for jobs. ### What's a lighter alternative to BullMQ on Redis? Bee-Queue for a simple, fast queue, or `@imqueue/job` for a minimal, safe-by-default queue — especially if you already use `@imqueue` for RPC. ### Is Bull deprecated? No. Bull (v3) is still maintained enough to run in production, but BullMQ is its successor and the recommended choice for new projects. Kue, a different older library, *is* deprecated — avoid it. ### Should I use a job queue or a workflow engine? If you need durable, long-running orchestration with complex state, a workflow engine (e.g. Temporal) is a different category worth evaluating. For discrete background jobs, a queue like the ones above is simpler and lighter. --- Want to try the minimal, safe-by-default option? [**Get started with @imqueue**](https://imqueue.org/get-started/), read the [**@imqueue/job vs BullMQ**](https://imqueue.org/blog/imqueue-vs-bullmq/) deep-dive, or browse the [**API reference**](https://imqueue.org/api/). Shipping inside a closed-source product? See [**commercial licensing & support**](https://imqueue.org/license/). --- # RPC between Node.js microservices over a message queue Source: https://imqueue.org/blog/rpc-over-message-queue-nodejs/ Published: 2026-07-22 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) Most Node.js microservices talk to each other over HTTP. It works, it's familiar, and it comes with a stack of accidental complexity: you run load balancers, you wire up service discovery, you handle retries and timeouts, you serialize and re-parse JSON on both ends, and you hand-write (or hand-maintain) a client for every service you call. There is another option that predates the microservice hype and still holds up well: **put a message queue between your services and do RPC over it.** This post explains when that's a good idea, what you give up, and how `@imqueue` turns it into typed, boilerplate-free calls. ## The problem with HTTP for internal calls HTTP is a great protocol for the edge — for talking to browsers, mobile apps, and third parties. For *internal* service-to-service traffic it drags along baggage you don't need: - **You need to know where the other service is.** That means service discovery, DNS, or a load balancer sitting in the request path. - **Scaling is coupled to addressing.** Add an instance and something has to notice and start routing to it. - **The contract is untyped by default.** A route returns whatever it returns; your caller finds out at runtime. Teams paper over this with OpenAPI generators, but that's another build step and another artifact to keep in sync. None of this is fatal. It's just a lot of moving parts for "service A wants to call a function in service B." ## What a queue changes When services communicate through a shared message queue, addressing becomes a non-problem. A service reads from its own named queue; a caller drops a message on that queue. If you run five instances of a service, they all read from the same queue and the queue hands each message to whichever instance is free. That's load balancing for free, with no component in the request path that you have to operate. - **No service discovery.** The queue name *is* the address. - **No load balancer.** Competing consumers on one queue balance themselves. - **Back-pressure is natural.** If consumers fall behind, the queue grows; it doesn't drop connections. A message queue is a particularly practical substrate for this: it's fast, and blocking queue operations let you build it with no polling and no idle CPU cost. In practice, `@imqueue` runs on a queue you very likely already operate. ## Where the types come from The usual objection to queues is: "great, but now I'm sending untyped JSON blobs around." That's the part `@imqueue` solves. A service is an ordinary TypeScript class; you mark the methods you want to expose, and the framework derives the service's description from your code and its JSDoc: ```ts import { IMQService, expose } from '@imqueue/rpc'; export class UserService extends IMQService { /** * Returns a user by id * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { return { id, name: 'Jane Doe' }; } } ``` Because the service is self-describing, a fully typed client can be **generated** from it — you never write the client by hand: ```bash imq client generate UserService ``` ```ts const client = new UserClient(); await client.start(); const user = await client.get('42'); // typed, autocompleted, no HTTP in sight ``` If `UserService.get` changes its signature, you regenerate the client and the type error shows up at compile time in every caller — the same safety a monorepo gives you, but across independently deployed services. ## What you give up This isn't free of trade-offs, and it's worth being honest about them: - **The message queue is now in the hot path.** @imqueue needs a running queue; for most teams that infrastructure is already there, and if it isn't, that's a real addition to operate. - **It's request/response over a broker, not a streaming protocol.** If you need bidirectional streaming or HTTP/2 semantics, this isn't that. - **It's a Node.js/TypeScript-first tool.** If you need first-class clients in Go, Rust, and Python too, a cross-language protocol like gRPC will fit better. ## When it's the right call Reach for queue-based RPC when your internal services are Node.js/TypeScript, you want typed calls without maintaining clients, and you'd rather not run discovery and load-balancing infrastructure just so services can find each other. That's the sweet spot `@imqueue` was built for. If that sounds like your architecture, the [Getting Started](https://imqueue.org/get-started/) guide takes you from an empty terminal to a running service and a generated client in a few minutes, and the [Tutorial](https://imqueue.org/tutorial/) builds a complete example app one service at a time. --- # Type-safe service-to-service communication in TypeScript Source: https://imqueue.org/blog/type-safe-service-communication-typescript/ Published: 2026-07-19 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) Inside a single TypeScript program, calling a function is safe: if you change its signature, every caller stops compiling until you fix it. That feedback loop is one of the best things about the language. The moment a call crosses a network boundary, that safety usually evaporates — the caller is now talking to a string URL and parsing a JSON blob, and the compiler has no idea what shape to expect. There are a few common ways teams try to get the safety back. They all work; they all cost something. ## The usual approaches **Shared type packages.** Publish an `@types/user-service` package and import it in the caller. This gives you types, but nothing guarantees the running service actually matches the published types — they drift, and the drift is invisible until production. You're maintaining the types by hand, twice. **A schema language / IDL.** Define the contract in Protobuf or similar, generate code for both sides. This is robust and cross-language, but it adds a schema to author and version, a code-generation step to your build, and a second source of truth that has to agree with your implementation. **OpenAPI generators.** Describe your HTTP API, generate a client. Good for public APIs; for internal calls it's a lot of ceremony, and the generated clients are only as accurate as the hand-written annotations behind them. Notice the common thread: in each case *you* maintain the contract as a separate artifact from the code that implements it. ## Let the code be the contract `@imqueue` takes a different stance: the service implementation **is** the contract. You write an ordinary class, document its methods with JSDoc (which you should be doing anyway), and mark the ones you want to expose: ```ts import { IMQService, expose, classType, property } from '@imqueue/rpc'; @classType() class User { @property('string') id: string; @property('string') name: string; @property('string', true) email?: string; // optional } export class UserService extends IMQService { /** * Persists a user and returns the saved record * @param {User} user - the user to save * @return {Promise} */ @expose() public async save(user: User): Promise { return user; } } ``` From that, the framework builds a description of the service — its methods, their parameter and return types, and any complex types — and **generates a typed client** from the running service: ```ts const users = new UserClient(); await users.start(); const saved = await users.save({ id: '1', name: 'Ada' }); // fully typed ``` The client isn't a hand-maintained mirror of the service; it's produced from the service itself. Regenerate it after a change and every caller that no longer matches fails to compile — exactly like an in-process function call, but between independently deployed services. ## Two details that matter **JSDoc carries the types.** `@imqueue/rpc` uses standard TC39 decorators, which deliberately carry no runtime type metadata. So the framework reads types from your JSDoc `@param`/`@return` tags. Keep them accurate — an undocumented parameter falls back to `any` in the generated client. The upside is that your documentation and your wire contract can't drift apart, because they're the same thing. **Complex types need registration.** Any object type you pass across the boundary must be a class annotated with `@classType()`, with each field marked by `@property()`. That's what lets those types show up, fully formed, in the generated client. ## The payoff You get end-to-end types across service boundaries with **no separate schema, no published type package to keep in sync, and no client to hand-write** — the three things that usually rot. The trade-off is that this is a Node.js/TypeScript-native approach; it's not trying to be a polyglot IDL. If your services are TypeScript and you want them to feel like typed function calls, that's the whole point. See [Getting Started](https://imqueue.org/get-started/) to try it, or the [API reference](https://imqueue.org/api/) for the full decorator and client-generation surface. --- # Stop hand-writing and maintaining your microservice clients Source: https://imqueue.org/blog/stop-hand-writing-microservice-clients/ Published: 2026-07-17 Updated: 2026-07-29 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) **A hand-written client is a copy of a service's contract, kept somewhere else, maintained by hand — and copies drift.** The fix isn't more discipline; it's removing the copy. If the service describes itself and the client is generated from that description, there's exactly one source of truth, and a contract change becomes a compile error instead of a runtime surprise. That's the model `@imqueue` uses, and it has real costs worth knowing before you adopt it. ## Why hand-written clients rot The problem isn't that clients are hard to write. It's that the contract lives in two places: - A parameter becomes optional, or a return field is renamed — and the client still describes the old shape. - Types are duplicated: the service defines `User`, the client re-declares `User`, and nothing forces them to agree. - **The drift is invisible.** Nothing fails to compile. Callers get subtly wrong types until something breaks at runtime, usually in production, usually far from the change that caused it. The usual remedies don't remove the copy, they just manage it: | Remedy | Why it doesn't close the gap | |---|---| | "Remember to update the client" | Discipline degrades under deadline; nothing enforces it | | A shared types package | Still a hand-maintained copy, now with its own release cycle | | A schema language plus codegen | Three sources of truth — schema, service, client | | Integration tests | Catches drift late, only on paths you thought to test | Each is more process on top of the same underlying issue. ## Make the client fall out of the service Have exactly **one** source of truth — the service — and derive the client from it. In `@imqueue` a service is an ordinary TypeScript class; you mark the methods you want reachable, and the framework builds the service's description from your code and its JSDoc: ```typescript import { IMQService, expose } from '@imqueue/rpc'; export class UserService extends IMQService { /** * Returns a user by id * * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { return { id, name: 'Jane Doe' }; } } ``` You don't write the client. You generate it from the running service: ```bash imq client generate UserService ./src/clients ``` ```typescript import { user } from './clients/index.js'; const users = new user.UserClient({ callTimeout: 5000 }); await users.start(); const found = await users.get('42'); // typed, autocompleted, no hand-written client ``` Note the class is `UserClient`, not `UserServiceClient` — the generator replaces a trailing `Service` with `Client` rather than appending. Now there's no copy to drift. When `UserService.get` changes, you regenerate and every caller whose usage no longer matches **fails to compile** — the mistake surfaces at build time, in your editor, instead of at runtime in front of a customer. ## What it looks like in a real workflow Generation needs the service running, because the client is built from the description the service reports. In practice that's a three-line loop, straight from the CLI guide: ```bash imq ctl start -s user -c # bring the service up and wait for readiness imq client generate user ./src/clients imq ctl stop -s user ``` Two shapes that work well around it: - **Commit the generated client.** It becomes a reviewable artifact: a diff on the generated file *is* the contract change, visible in code review, versionable, and shippable on the caller's own schedule. - **Regenerate in CI on the service's release**, and open a PR against consumers. The compile step then tells you which callers break, before anyone deploys. Either way the client is a build output, not a hand-maintained module. ## What generated clients remove from your week - **No SDK to publish and version by hand** for each service. - **No duplicated type declarations** to keep in agreement. - **No "did someone update the client?" ritual** in review. - **No silent drift** — a breaking change is a compile error, not a 2 a.m. page. ## What it costs Being straight about this matters more than the pitch, because the costs are real and mostly front-loaded. **JSDoc is not documentation, it's the contract.** The generator reads your doc-block, not TypeScript's own type information, so: - An undocumented parameter falls back to `any` in the generated client — you lose typing silently, exactly the failure mode you adopted this to avoid. - The `@param` count must match the method's real arity, or calls fail with `IMQ_RPC_INVALID_ARGS_COUNT`. - Consuming projects must compile with `removeComments: false`, or the generator has nothing to read. **Some shapes aren't expressible.** Rest and spread parameters on exposed methods are a documented limitation — the generated client won't compile. Pass an array instead. Only methods can be exposed, not properties, and `@expose()` on a static method silently registers under a pseudo-class name and stays unreachable. **Decorator order is load-bearing.** `@expose()` must sit innermost — closest to the method — when combined with `@lock()`, `@cache` or `@logged()`. Those replace the method with a `(...args)` wrapper, so applying `@expose()` after them records the rest parameter as the only argument and breaks both argument validation and the generated signature. **The service has to be up to generate.** That's a real step on a fresh checkout and in CI, and it means client generation can't happen in a pure offline build. **Generation is a moment in time.** Nothing detects that a *running* peer has drifted from the client you generated last month. The compile error only arrives when someone regenerates — which is why [versioning your service contracts](https://imqueue.org/blog/versioning-microservices-without-breaking-callers/) still matters. None of these are dealbreakers, but the first one catches most newcomers: your doc-blocks are now load-bearing code. ## How this compares to the alternatives Generated-from-the-service isn't the only way to get typed calls, and it isn't always the best one: - **tRPC** infers types across a shared TypeScript project with no generation step at all. Better ergonomics, at the cost of needing both sides in one compilation unit — [the full comparison](https://imqueue.org/blog/imqueue-vs-trpc/). - **gRPC** puts the contract in a `.proto`, which is language-neutral and reviewable independently of any implementation — worth it if your fleet isn't all Node, and [compared here](https://imqueue.org/blog/grpc-vs-message-queue-rpc/). - **A shared types package** is fine when one team owns both sides and the release cadences already match. The generated approach wins specifically when services are TypeScript, deployed independently, and owned by different people. ## Frequently asked questions about generated service clients ### Do I have to commit the generated client? No, but it's usually worth it. Committed, the diff on that file is the contract change — visible in review, versioned with the caller. ### Does the service need to be running to generate a client? Yes. The client is built from the description the running service reports, so generation requires the service up and Redis reachable. ### What happens if I forget to regenerate? Your caller keeps compiling against the old shape and can fail at runtime — the same drift as a hand-written client. Generation removes the *copy*, not the need to regenerate on change. Wire it into the service's release process. ### Why is JSDoc required if TypeScript already has types? The generator reads the doc-block rather than reflecting on types at runtime, which is why the annotations are mandatory and why `removeComments: false` matters. It's the main friction in the model. ### Can I hand-edit the generated client? Don't. It's a build output and the next generation overwrites it — existing files are replaced silently. Put anything custom in a wrapper around it. ### Does this work across repositories? Yes, and that's the point of generating rather than inferring. The client is an artifact, so callers can live anywhere and regenerate on their own schedule. --- If maintaining a drawer full of clients is draining your team, this is the shape of the fix. [**Getting Started**](https://imqueue.org/get-started/) walks from an empty terminal to a generated client in a few minutes, and [type-safe service communication in TypeScript](https://imqueue.org/blog/type-safe-service-communication-typescript/) covers the typing model in more depth. Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # Load balancing microservices without a load balancer Source: https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/ Published: 2026-07-14 Updated: 2026-07-29 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) **Competing consumers is load balancing without a balancer: instead of something choosing an instance and pushing work to it, idle instances pull work from a shared queue.** Every replica of a service reads the same named queue, so whoever is free takes the next message. There's no component in the request path deciding anything, nothing to register an instance with, and no health checks feeding a routing table. For internal service-to-service traffic that's usually a better deal than the balancer you'd otherwise operate. ## The problem with a balancer in the internal path When service A calls service B over HTTP, something must decide *which instance* of B gets the request. That decision usually lives in a load balancer or service mesh. It works, but it means: - **You operate another moving part** — configure it, monitor it, scale it. - **Addressing and scaling are coupled.** Add an instance of B and the balancer has to learn about it, via discovery, health checks or registration. - **It's in the hot path.** Every internal call traverses it, so its latency and availability become yours. ## Why pulling beats pushing: competing consumers vs a load balancer Pull-based distribution is the actual mechanism behind competing consumers, and worth understanding on its own rather than as "one less component". A load balancer **pushes**. It picks a target using a policy — round-robin, least-connections, random — and hands the request over. The policy is a guess about which instance can best handle the work right now, made by something that isn't that instance. Round-robin will happily hand a request to an instance that's mid-way through something expensive; least-connections is better but still infers load from connection count rather than from actual capacity. ~~~mermaid flowchart LR subgraph push["Push — a load balancer chooses"] direction LR P1[caller] --> LB[load balancer] LB -->|"round-robin: next in sequence"| B1["instance 1 — busy, 5s handler"] LB --> B2["instance 2 — idle"] end subgraph pull["Pull — @imqueue competing consumers"] direction LR P2[caller] -->|"message"| Q[("queue 'Thumbnail'")] B3["instance 1 — busy, not asking"] -.-> Q B4["instance 2 — idle, asks"] -->|"takes it"| Q end ~~~ A load balancer hands work to an instance it picked; `@imqueue`'s competing consumers hand work to whichever instance asked for it. That is the whole difference, and it is why the busy instance in the lower half receives nothing. Competing consumers **pull**. An instance takes the next message when it's ready for one, so "who is free" isn't estimated — it's expressed by the act of asking. An instance chewing on something slow simply doesn't ask for more work, and the queue hands the next message to one that does. You get load-aware distribution without any load-awareness logic, and without a component that has to be told about capacity. In `@imqueue` this is the default and there's nothing to configure. Run more instances of a service: ```typescript import { IMQService, expose } from '@imqueue/rpc'; export class Thumbnail extends IMQService { /** * Generates a thumbnail and returns its URL * * @param {string} imageId - source image id * @return {Promise} - thumbnail URL */ @expose() public async make(imageId: string): Promise { // ...heavy work; run as many instances as you need return `https://cdn.example.com/thumbs/${imageId}.jpg`; } } ``` Start three copies and callers keep calling `client.make(...)` exactly as before — the extra instances just share the load. No registration, no health-check wiring, no balancer config. It's also not a polling loop. The implementation uses blocking queue operations rather than timers, so an idle worker costs no CPU and a message doesn't wait for the next tick of anything. ## Three ways to add capacity with `@imqueue` `@imqueue` gives you three levers — more service instances, more workers inside one process, and more Redis nodes. They compose, and they fail differently, so it's worth knowing which you're using. **More service instances.** The plain case above: separate processes, possibly on separate machines, all consuming the same queue. Nothing to configure. **More workers inside one process** via `multiProcess`, which forks one cluster worker per CPU core (times `childrenPerCore`). Two documented gotchas here, both easy to trip over: - The primary **also** starts its own consumer after forking, so N configured workers means N+1 consumers — and N+1 processes will try to bind the metrics port. - **Workers are never respawned.** When one dies, an exit watcher kills the whole process with code 1, deliberately leaving supervision to your process manager. Don't enable this without one. **More Redis nodes**, via clustering. Here the asymmetry matters: `send()` routes each message to exactly one server using health-aware round-robin that skips instances whose writer connection isn't ready — but every *other* operation (`start`, `stop`, `clear`, `publish`, `subscribe`, `queueLength`) fans out to every server using `Promise.all`. So one failing host fails the whole call, with no partial-failure reporting and no rollback. [Horizontally scalable Redis broker](https://imqueue.org/blog/horizontally-scalable-redis-broker/) goes through the topology properly. ## What a load balancer still does better than competing consumers Competing consumers gives up capabilities a balancer genuinely has, and being straight about them matters more than the pitch: - **Weighted and policy-based routing.** Canary deploys, traffic splitting, "send 5% to the new version" — competing consumers has no notion of any of it. Every consumer on a queue is equal. - **Per-instance health.** A balancer knows which instance is unhealthy and stops sending to it. A queue has no view of your instances at all; an instance that consumes messages and then fails at them will happily keep consuming. - **Fail-fast.** A balancer with no healthy backends returns an error immediately. A queue accepts the message and waits, which is why `callTimeout` is not optional in production — it's unset by default, and without it a call to a service with no consumers hangs forever. - **Circuit breaking and retry policy.** Not present; some of the need is absorbed by messages waiting rather than failing, but the features aren't there. - **Edge traffic.** Browsers can't talk to the queue. You still want an HTTP front door. ## Fairness and head-of-line effects on a shared `@imqueue` queue Once distribution is consumption-driven, two effects of a shared queue are yours to design for. **Mixed durations on one queue.** If a service has both 5 ms and 5 s methods, a burst of slow calls occupies your consumers and the fast calls queue behind them. The queue does not preempt; nothing interrupts a slow handler or re-queues it for taking too long. Where this bites, split the slow work onto its own service so it gets its own queue and its own scaling curve. **Duplicates are possible.** Delivery is at-least-once in both modes, so handlers should be idempotent. Safe delivery re-queues a message a dying worker never *started*; it doesn't protect work already in flight, and nothing drains in-flight work on shutdown — see [graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) and [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/). ## How you know competing consumers is keeping up Without a balancer you lose its dashboard, so watch the queue instead. `@imqueue` can expose a `queue_length` metric, and the signal you want is **sustained growth** — that means arrival rate has outrun your consumers and it's time to add some. Read it with care: `queueLength()` excludes messages not yet due and messages currently leased under safe delivery, so it isn't a measure of outstanding work, and it returns `0` when there's no writer connection — which makes "disconnected" indistinguishable from "empty". Alert on the trend, not the absolute number, and never treat zero as proof of health. ## When competing consumers is the right trade Use competing consumers — over `@imqueue` or any other queue — for internal calls when your services are Node.js, you already run Redis or don't mind adding it, your handlers can be idempotent, and you don't need weighted routing or canaries on internal traffic. Keep the balancer or mesh when you need traffic policy, per-instance health, mTLS and tracing, fail-fast semantics, or you're routing between languages — [gRPC and a mesh are the better answer there](https://imqueue.org/blog/grpc-vs-message-queue-rpc/). ## Frequently asked questions about load balancing without a balancer ### How does a message queue decide which service instance gets a message? A queue does not decide, in the sense a balancer does. Instances pull when they're ready, so the next free consumer takes the next message. Distribution is a consequence of consumption rather than a routing decision. ### Are competing consumers the same as round-robin? No, and that's the point. Round-robin sends to the next instance in sequence whether or not it's busy. Competing consumers sends to whichever instance asks, which is inherently load-aware. ### Do I still need a load balancer anywhere? Yes — at the edge, for public HTTP traffic. Competing consumers is about services calling each other. ### How do I scale an `@imqueue` service? Run more instances of it. They join the same queue and start taking work immediately, with no registration step. ### What if one service instance is much slower than the others? That instance asks for work less often, so it naturally receives less. Self-throttling is the main advantage competing consumers has over push-based balancing. ### Can I do canary deploys with competing consumers? Not with the queue alone — every consumer on an `@imqueue` queue is equal, and there's no weighting. Deploy a canary as a separate service with its own queue, and split traffic at the caller or the gateway. --- The [**Tutorial**](https://imqueue.org/tutorial/) builds a multi-service app where scaling a service is just running more of it. For the addressing half of the same mechanism, see [do your Node.js back-ends really need service discovery?](https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/) Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # Do your Node.js back-ends really need service discovery? Source: https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/ Published: 2026-07-12 Updated: 2026-07-29 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) **Service discovery exists because HTTP calls an address.** To call service B you need a host and a port, and because instances come and go, you need something keeping a live map of where a healthy B is right now — Consul, etcd, Eureka, DNS-SD, or your platform's registry. Route calls through a message queue instead and the question dissolves: a service reads from its own *named* queue, so the queue name is the address and there's no host to resolve. That's a real simplification, and it costs you things worth knowing about before you choose it. ## What service discovery is really for Discovery is the answer to "where is a healthy instance of B?", and it's usually paired with health checks and a load balancer or mesh. It's the right tool when: - You have a **polyglot** fleet where many kinds of clients need to locate services. - You're already invested in a **service mesh** and want its traffic policies, mTLS and observability. - You need discovery for things **beyond RPC** — config distribution, leader election, distributed coordination. If that's you, keep it; it earns its place. A queue replaces the addressing job only. ## What changes with a queue With `@imqueue`, a service consumes from a queue named after it, and instances of that service compete on the same queue: - All instances read the same named queue, so *which* instance handles a call is decided by consumption, not by a registry. - Starting or stopping instances changes nothing anyone has to discover — they attach to or detach from the same queue. - There's no live host map, and no health-check plumbing feeding a registry. The caller just uses a generated client: ```typescript import { user } from './clients/index.js'; const client = new user.UserClient({ callTimeout: 5000 }); await client.start(); const found = await client.get('42'); // no host, no port, no registry lookup ``` The caller never knows or cares how many instances of the `User` service exist, or where they run. [Load balancing without a load balancer](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/) covers the distribution side of the same mechanism. ## The nice consequence: boot order stops mattering Order-independent boot is the part people don't expect. Because an `@imqueue` call is a message on a queue rather than a connection to a host, a caller can send before the callee exists. The message waits. Services can start in **any order**, and a service that isn't up yet simply accumulates work until a consumer appears. That removes a whole category of orchestration: no readiness gates between services just so A doesn't crash-loop while B boots, no retry-with-backoff on startup, no dependency ordering in your compose file. One caveat that matters: this holds for **pre-generated static clients**, the ones you commit. A *dynamic* client created at runtime with `IMQClient.create()` has to ask the running service for its description, so it does require the target to be up. If order-independence is the property you want, generate your clients ahead of time. ## What replaces each job discovery was doing | Discovery's job | With a queue | |---|---| | Find a host for B | Nothing to find — the queue name is the address | | Balance across B's instances | Instances compete on the queue | | Notice B is unhealthy and route around it | Nothing routes; unconsumed work waits on the queue | | Notice B is gone entirely | **Nothing tells you** — see below | | Distribute config, elect leaders | Not covered; still your problem | Four of five rows get simpler. The fourth is the one to think hard about. ## The honest cost: "where is B" becomes "is B ever coming?" A registry with health checks gives you an explicit answer to "is B alive". A queue gives you no such thing, and that trade is the real content of this decision. With HTTP plus discovery, a call to a service with no healthy instances fails fast and loudly — connection refused, or a `503` from the mesh. With a queue, the same call is indistinguishable from a call to a service that's merely busy. It waits. Two consequences: - **Set `callTimeout`.** It's unset by default, which means a call to a service that never answers stays pending *forever*. This is the single most important option to set in production, and the queue model is exactly why. - **A timeout is not a diagnosis.** `IMQ_RPC_CALL_TIMEOUT` tells you nobody answered in time. It doesn't distinguish "still booting", "overloaded", "crash-looping" and "never deployed". A registry would have told you which. So you don't escape observability work — you relocate it. Instead of registry health, you watch **queue depth**: `@imqueue`'s optional metrics server exposes a `queue_length` metric, and a queue growing without bound is the signal that a consumer has stopped consuming. Read that metric carefully, though. `queueLength()` excludes messages that aren't due yet and messages currently leased under safe delivery, so it isn't a measure of outstanding work — and it returns `0` when there's no writer connection, which makes "disconnected" look identical to "empty". Alert on *growth*, not on absolute depth, and don't treat zero as healthy on its own. ## A fair accounting of the rest - **You depend on the queue being available** the way you'd depend on a registry or mesh. With `@imqueue` that's Redis, in the path of every internal call — a stateful dependency whose availability becomes your RPC layer's availability. For most teams it's infrastructure they already run; it's still a dependency you should reason about rather than wave through. - **Delivery is at-least-once.** Because a message can be delivered twice, exposed methods should be idempotent. That's a design constraint HTTP-plus-discovery doesn't impose. See [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/). - **Nothing drains in-flight work on shutdown.** A worker killed mid-handler loses that message. [Graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) works through what closing that gap actually takes. - **Non-RPC needs are untouched.** Config distribution, leader election and coordination still need the right tool; a queue doesn't replace them. - **Cross-language fleets** may still want a language-neutral discovery story — [gRPC is the better answer there](https://imqueue.org/blog/grpc-vs-message-queue-rpc/). ## So does your Node.js back-end need service discovery? A short test. You probably **don't** need a discovery layer if: - Your back-end services are all Node.js or TypeScript. - The only reason you were about to install Consul is "so my services can find each other to make calls". - You already run Redis, or don't mind adding it. - You can make your handlers idempotent. You probably **do** still need one if: - Your fleet is polyglot, or clients outside your control need to locate services. - You want a mesh's traffic policy, mTLS or tracing. - You need discovery for config, coordination or leader election as well. - Fail-fast semantics matter more to you than buffered ones — some systems genuinely want the call to explode immediately rather than wait. ## Frequently asked questions about service discovery in Node.js ### Is a message queue a replacement for Consul or etcd? For service-to-service *addressing*, largely yes — the queue name is the address. For config distribution, leader election and coordination, no. Those are separate jobs a queue doesn't do. ### How do I know a service is healthy without a registry? You watch the queue rather than the service. `@imqueue` can expose a `queue_length` metric; sustained growth means a consumer has stopped keeping up. You don't get a registry's per-instance health view, and you should set `callTimeout` so callers fail rather than hang. ### What happens if a service is down when someone calls it? The message waits on its queue until a consumer appears, so the call isn't lost. The risk is the opposite of HTTP's: instead of failing fast, it can wait indefinitely — which is why `callTimeout` matters. ### Does this work across multiple machines? Yes. Instances anywhere that can reach the same Redis compete on the same queue, so horizontal scaling needs no addressing changes. ### Do I still need a load balancer? Not for internal service-to-service calls; competing consumers handle distribution. You'll still want something in front of your HTTP edge. ### Doesn't this just move the single point of failure to Redis? It concentrates the dependency, yes — honestly, that's the trade. A registry or mesh is also a critical dependency; the question is which one you'd rather operate, and whether you're already running it. --- If a queue-based model sounds like it fits, [**Getting Started**](https://imqueue.org/get-started/) shows how calls work with no addressing on your side, and [horizontally scalable Redis broker](https://imqueue.org/blog/horizontally-scalable-redis-broker/) covers what happens when one Redis isn't enough. Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # Back-pressure for Node.js services Source: https://imqueue.org/blog/backpressure-nodejs-services/ Published: 2026-07-09 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) Every system has a breaking point under load. What matters is *how* it breaks. With synchronous HTTP calls between services, a slowdown in one place tends to propagate outward into a cascading failure. With a queue between services, the same spike gets absorbed. Understanding why is worth more than any single resilience library. ## Why HTTP struggles under load Say service A calls service B over HTTP, and B gets slow — a dependency is struggling, or traffic spiked. Here's the chain reaction: - A's requests to B pile up as **open connections**, each holding memory and a socket while it waits. - A's own callers are now waiting on A, so the pressure propagates **upstream**. - Timeouts fire, clients **retry**, and retries add *more* load to the very service that's already overwhelmed. - Eventually connection pools exhaust and healthy requests fail alongside the unhealthy ones. Teams patch this with timeouts, circuit breakers, and bulkheads — all genuinely useful — but they're mechanisms to *fail faster*, layered on top of a transport that couples caller and callee tightly in time. ## What a queue changes When A talks to B through a message queue, A doesn't hold a connection open waiting for a free instance of B. It puts a message on B's queue. If B is momentarily slower than its inflow, the **queue grows** — it buffers the spike rather than converting it into failed connections. B works through the backlog at its own pace, and when consumers catch up, the queue drains. That's back-pressure done structurally: the queue is a shock absorber between producer and consumer. Add more consumers (more instances of B) and the backlog clears faster, with no balancer to reconfigure. `@imqueue` gives you a couple of levers that matter here: - **Delivery mode.** In guaranteed (safe) mode the hand-off from queue to consumer is protected: a message taken by an instance that died before starting on it is rescheduled rather than lost. You trade some throughput for that. Work already inside a handler when the process dies is a separate problem, and the answer to it is [draining on shutdown](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/). - **A recovery deadline.** `safeDeliveryTtl` governs how quickly an abandoned hand-off returns to the queue — not how long a handler may run. Slow-but-healthy work is not re-queued for being slow, so tune it for how fast you want recovery, not against your worst-case processing time. ## The trade-offs to respect A queue changes the failure mode; it doesn't repeal physics: - **A backlog is latency.** Buffering a spike means requests wait. That's usually far better than failing them, but for user-facing, latency-critical calls you still want enough consumers that the queue stays shallow. - **Unbounded growth is its own failure.** If inflow *permanently* exceeds what your consumers can handle, the queue grows without bound. Monitor queue depth and scale consumers (or shed load deliberately) — a growing backlog is the signal. - **Not everything should wait.** Truly time-sensitive interactive calls may still want a fast-fail path; use back-pressure where deferral is acceptable. The headline: HTTP couples services in time, so overload spreads; a queue decouples them, so overload is absorbed and bounded by how fast you choose to consume. If cascading failures under load are a recurring pain, that structural difference is the lever. See [Getting Started](https://imqueue.org/get-started/) to try the model, and the delivery-mode options in the [API reference](https://imqueue.org/api/). --- # Guaranteed message delivery: cost and when to use it Source: https://imqueue.org/blog/guaranteed-message-delivery-cost/ Published: 2026-07-07 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) The first serious question anyone asks about a message-based system is: "if a worker grabs a message and then crashes, is that message lost?" It's the right question. The answer isn't a simple yes or no — it's a trade-off you should make deliberately, per workload, with your eyes open about the cost. ## Two honest modes `@imqueue` offers two delivery modes, and the difference is exactly about that crash scenario. **Unreliable (fast) delivery.** A consumer takes a message and processes it. If it crashes before finishing, that message is gone. This is the fastest mode — there's no bookkeeping — and it's the right default for work that is frequent, idempotent-on-retry-elsewhere, or simply not costly to miss (think best-effort notifications, cache warmups, telemetry). **Guaranteed (safe) delivery.** As a consumer takes a message, it's atomically moved into that consumer's own "processing" holding area, so a process that dies *between* taking a message and starting on it leaves the message behind to be **rescheduled** to another instance instead of swallowing it. The protection covers that hand-off rather than the whole handler: the entry is released once the message reaches your code, so a consumer killed part-way through the work loses that attempt like any other. This is still what you want for work that must not vanish — placing an order, charging a card, kicking off a payout — paired with a [drain on shutdown](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) for the planned case. ## What guaranteed delivery costs Safety isn't free, and it's useful to know the shape of the bill. In a recent `@imqueue/core` benchmark run — a 24-core Intel Core Ultra 9 275HX, Node.js 24, ~1 KB messages — unreliable delivery reached **~200,000 round-trip msg/sec** and guaranteed delivery **~120,000 msg/sec**. So safe mode costs roughly **40% of throughput** (about 1.7× slower), keeping ~60%. Your numbers will differ with hardware and message size — see the [benchmark post](https://imqueue.org/blog/benchmarking-imqueue-throughput/) for the full run and how to reproduce it. That's a very reasonable price for "never lose this message," and the key insight is that **you don't pay it globally.** You choose per queue. Latency-critical, loss-tolerant paths stay in the fast mode; critical paths run safe. You're not forced into one guarantee for the whole system. ## The knob, and what it actually governs Guaranteed mode stamps each hand-off with a **time-to-live** (`safeDeliveryTtl`, default 5 seconds; `safeLockTtl` through `@imqueue/job`). A sweep running on that same interval reclaims holding-area entries whose TTL has passed and puts them back on the queue. That's the mechanism behind rescheduling — and it is easy to read more into it than it does: > A slow-but-healthy task is **not** re-queued for being slow, and raising `safeDeliveryTtl` does not extend any protection over a long-running handler. The entry is released when the message reaches your code, so the TTL is a recovery deadline for an abandoned hand-off, not a processing deadline. So tune it for recovery latency rather than against your p99: shorter brings an abandoned message back sooner, longer sweeps less often. What survives a restart that lands mid-handler is a [drain on shutdown](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/), not this TTL. ## A quick decision guide Ask two questions about each kind of message: 1. **If this is lost, does it matter?** If no → unreliable is fine, and faster. If yes → guaranteed. 2. **Is the work idempotent?** Guaranteed delivery is *at-least-once*: a message can be processed more than once (a hand-off whose release didn't land can be swept back onto the queue while the first consumer is still working, and `@imqueue/job` re-sends a job whose handler throws). So design critical handlers to be **idempotent** — safe to run twice — using an idempotency key or a dedupe check. This matters regardless of framework; at-least-once is the honest guarantee, not exactly-once. Match the mode to the message and you get the best of both: raw speed where loss is acceptable, and durability where it isn't — without paying for durability everywhere. The delivery options are covered in the [API reference](https://imqueue.org/api/); [Getting Started](https://imqueue.org/get-started/) gets you a service to try them on. --- # Cutting the boilerplate out of Node.js microservices Source: https://imqueue.org/blog/cutting-boilerplate-nodejs-microservices/ Published: 2026-07-04 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) Open a fresh microservice repo and look at what's there before any business logic exists: transport setup, request/response serialization, a client for other services to call you, a Dockerfile, a CI pipeline, config plumbing. That's the boilerplate tax — and because it's per-service, it scales linearly with your fleet. Every new service is another round of copying, tweaking, and forgetting one thing. ## Where the boilerplate actually hides It helps to name the categories, because they have different fixes: 1. **Transport & wiring** — connecting to the message bus, framing requests and responses, routing a call to the right handler. 2. **Serialization & types** — turning calls into messages and back, and keeping argument/return types correct on both ends. 3. **Clients** — the code other services use to call you, which someone has to write and keep in sync. 4. **Project setup** — repo layout, TypeScript config, a Dockerfile, a CI workflow, a container registry push. Most teams hand-roll all four for every service. The first three are exactly what a good RPC framework should absorb; the fourth is what a good scaffolding tool should generate. ## Let the framework own the wiring With `@imqueue`, categories 1–3 mostly disappear. A service is just a class with the methods you expose; the framework handles transport, framing, and serialization, and the **client is generated from the service** rather than written: ```ts import { IMQService, expose } from '@imqueue/rpc'; export class OrderService extends IMQService { /** * Places an order and returns its id * @param {{ items: string[]; userId: string }} order * @return {Promise<{ id: string }>} */ @expose() public async place(order: { items: string[]; userId: string }): Promise<{ id: string }> { return { id: 'ord_123' }; } } ``` There's no transport code in that file, no serializer, and no client to maintain — callers generate one with `imq client generate OrderService` and call `orders.place(...)` with full types. ## Let the CLI own the setup Category 4 — the repo scaffolding — is what [`@imqueue/cli`](https://imqueue.org/cli/) is for. Instead of copying a template repo and editing it by hand, you scaffold a service and its surrounding setup in one command: ```bash imq service create ``` The CLI lays down the service structure, wires up your VCS/CI/registry providers, and can generate clients and run a local fleet of services during development. The ceremony that used to be a checklist becomes a command. ## The point isn't fewer keystrokes It's fewer places to make a mistake. Hand-written wiring and clients aren't just tedious — they're where inconsistencies creep in: one service serializes dates differently, another's client is a version behind, a third's CI is missing a step. Generating that layer makes services uniform by construction, which pays off most when you have a lot of them. If your services' first hundred lines are always the same hundred lines, that's boilerplate a framework and a scaffolder should be writing for you. Start with [Getting Started](https://imqueue.org/get-started/), or jump into the [CLI User Guide](https://imqueue.org/cli/). --- # Versioning microservices without breaking every caller Source: https://imqueue.org/blog/versioning-microservices-without-breaking-callers/ Published: 2026-07-02 Updated: 2026-07-29 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) **Breaking changes between services are dangerous because they're silent: the caller keeps compiling against its old idea of the contract and fails at runtime instead.** Making them safe is mostly about making them *loud* — early, and at build time. Generated clients get you most of the way by turning a contract change into a compile error, but they don't cover the window where both versions are running at once, and that window is where the actual outages happen. ## Why microservice breakage goes silent In a typical setup the caller's knowledge of a service is a hand-maintained client, or assumptions baked into request code. When the service changes, nothing forces the caller to notice: - The caller still compiles against its old idea of the contract. - Tests pass, because they test the caller against its own — now stale — assumptions. - The mismatch surfaces only when a real call hits the changed service. In production, usually, and far from the change that caused it. The root cause is the one behind so much microservice pain: the contract lives in two places and nothing checks that they agree. [Generated clients remove the copy](https://imqueue.org/blog/stop-hand-writing-microservice-clients/), which is where this gets tractable. ## Make breakage a compile error with generated `@imqueue` clients Because `@imqueue` clients are generated from the service, the contract has one source of truth, and you can turn a breaking change into a build failure: 1. Change the service method. 2. Regenerate the client — `imq client generate [path]`. 3. Every caller whose usage no longer matches **fails to compile.** ```typescript // Service change: `get(id: string)` becomes `get(id: string, opts: GetOpts)`. // After regenerating, this caller no longer compiles — you find out in CI, // not at 2 a.m. in production: const user = await users.get('42'); // TS2554: expected 2 arguments, but got 1 ``` The compiler becomes your integration test for contract changes. You still decide *how* to evolve the API; you just can't do it silently any more. Two limits worth stating plainly, because they're easy to over-read: - **The error arrives at regeneration, not at deploy.** Nothing detects that a running peer has drifted from a client generated last month. If regeneration isn't wired into the service's release process, you've bought a slower version of the same problem. - **It's a compile check, not a runtime one.** A caller that never recompiles keeps calling the old shape. Which brings us to the part most articles skip. ## The mixed-version window, and why a queue makes it non-optional The mixed-version window is where real incidents come from, and no amount of type generation removes it. Between the moment you deploy service B v2 and the moment every caller has been regenerated and redeployed, **both versions of the contract are live**. With a queue in between, that window has a specific shape: - Messages sent by an old caller may be consumed by a **new** instance of B. - Because delivery is **at-least-once** and instances compete on the same queue, you don't control which version handles which message. A rolling deploy means a mix. - Anything already queued when you deploy will be handled by whatever consumes it next — which may be the new code. So the rule is not "regenerate and ship". The rule is: **a service must be able to handle requests from the previous contract for as long as any caller might still send them.** That's ordinary API compatibility discipline, and the queue makes it non-optional rather than merely advisable. Practical consequence for deploy order: ship the *service* first, in a backward-compatible form, then the callers. Never the reverse — a caller sending a v2-shaped request to a v1 service has nothing to fall back on. ## Which contract changes are actually safe in `@imqueue` | Change | Safe? | Notes | |---|---|---| | Add a new exposed method | Yes | Nothing calls it yet | | Add an **optional** parameter | Yes | Old callers omit it | | Add a field to a returned object | Usually | Safe unless callers do exhaustive checks | | Widen an input type | Yes | Accepting more is backward-compatible | | Add a **required** parameter | **No** | Old callers fail argument validation | | Rename a method or parameter | **No** | Old callers call something that no longer exists | | Remove a method | **No** | Deprecate first | | Narrow an input type | **No** | Previously valid calls become invalid | | Change a return type's shape | **No** | Callers destructure what isn't there | One `@imqueue` specific: argument **count** is validated at the boundary, and a mismatch fails with `IMQ_RPC_INVALID_ARGS_COUNT`. So adding a required parameter isn't a subtle type problem — it's an immediate rejection for every old caller. That's arguably better than silent misbehaviour, but it's a hard break. Also remember the `@param` tags in your doc-block *are* the contract. Changing the JSDoc without changing the signature is a contract change, and changing the signature without the JSDoc means the generated client won't reflect what you did. ## Deprecate an exposed method, don't mutate it Deprecation is the pattern that avoids most breakage on an `@imqueue` service: 1. Add the new method alongside the old one. Both exposed, both working. 2. Regenerate clients. Callers migrate at their own pace — nothing breaks, because nothing was removed. 3. Watch for calls to the old method. 4. Remove it once nobody calls it, and regenerate again. Slower than editing in place, and much cheaper than an incident. For a method you can't cleanly duplicate, add an optional options object and branch on it — an optional parameter is backward-compatible, a required one isn't. ## Coordinating a version change across an `@imqueue` fleet Sometimes a change genuinely has to ripple across many services — a shared type, a cross-cutting dependency bump. Doing that by hand, repo by repo, is where mistakes get made. [`@imqueue/cli`](https://imqueue.org/cli/) includes a fleet-wide version workflow (`imq service update-version`, with `--bump`) to roll a version change across many services in a coordinated way rather than editing each one individually. For the mechanics of running several services together while you do it, [isolated imq CLI environments](https://imqueue.org/blog/isolated-imq-cli-environments/) covers keeping fleets from colliding on one machine. ## A checklist for changing an `@imqueue` service contract - Is the change additive? If not, can it be expressed additively? - Will the service accept the **old** shape for as long as old callers exist? - Is the service deploying **before** its callers? - Have you regenerated clients and let the compiler find the call sites? - Is anything already sitting in the queue that the new code will consume? - Are the JSDoc annotations updated alongside the signature? ## Frequently asked questions about versioning @imqueue services ### Do generated clients eliminate breaking changes? No. They make breaking changes *visible* at compile time after regeneration. They don't make an incompatible change compatible, and they don't cover callers that haven't recompiled. ### How do I version a service — v1/v2 method names, or separate services? For most changes, neither: add an optional parameter or a new method and deprecate the old one. Reach for a parallel `getV2` only when the shapes genuinely can't coexist in one signature, and treat it as debt to remove. ### What about messages already in the queue during a deploy? Messages already queued are handled by whatever consumes them next, which may be the new code. That's the core reason a new version must still understand the old request shape. ### Can I run two versions of the same @imqueue service at once? Two versions under one service name compete on the same queue, so requests are split between them unpredictably. If you need genuine version isolation, give the new version its own service name and route at the caller. ### How do I know a deprecated method is unused? Log calls to it and watch. There's no built-in usage tracking, so instrument the method before you plan its removal. ### Does @imqueue check the contract at runtime? `@imqueue` validates argument count and rejects mismatches with `IMQ_RPC_INVALID_ARGS_COUNT`. It doesn't deep-validate payload shapes — if you need schema validation, add it yourself. --- Additive-by-default APIs, breakage surfaced at compile time by regenerated clients, and a deploy order that never puts callers ahead of services — that's a sane story for change. To see the generated-client model that makes it work, start with [**Getting Started**](https://imqueue.org/get-started/). Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # Testing services that call each other Source: https://imqueue.org/blog/testing-microservices-without-the-whole-stack/ Published: 2026-06-29 Author: Serhiy Morenko — Maintainer, @imqueue (https://github.com/SerhiyGreench) The dread of microservice testing is the end-to-end setup: to test one behavior, you boot every service it touches, plus their datastores and the message bus, wait for it all to come up, and hope nothing is flaky today. Those tests are valuable but expensive, and if they're your *only* tests, your feedback loop is measured in minutes. The fix isn't a clever harness — it's testing at the right layers so you rarely need the whole stack. ## Layer 1: a service is just a class The most underused fact about a well-structured `@imqueue` service is that it's an ordinary TypeScript class. Its exposed methods are normal methods. You can unit-test them directly, with no queue, no transport, no other service running: ```ts import { UserService } from '../src/UserService.js'; test('get returns null for an unknown id', async () => { const svc = new UserService(); const result = await svc.get('does-not-exist'); assert.equal(result, null); }); ``` Most of a service's logic lives in these methods. Testing them as plain functions is fast and deterministic, and it covers the majority of your behavior without any infrastructure at all. Keep the exposed methods thin over well-factored internals and this layer does the heavy lifting. ## Layer 2: typed clients are your seams When a service *calls another service*, you don't want the real callee in a unit test. Because callers depend on a **generated typed client**, that client is a clean seam to substitute. Inject the client (or the specific dependency it provides) rather than reaching for it globally, and in tests you pass a fake that satisfies the same typed interface: ```ts // Production wiring passes a real generated client; tests pass a fake // that implements the same typed shape. const fakeUsers = { async get(id: string) { return { id, name: 'Test User' }; }, }; const orders = new OrderService(fakeUsers); const result = await orders.placeFor('42'); assert.equal(result.status, 'ok'); ``` The win of a *typed* client here is that your fake can't drift from the real contract without a compile error — if `UserService.get` changes, both the real client and your fake stop type-checking, and you find out immediately. ## Layer 3: one real service + the queue, when you must Some things only integration tests can catch: serialization edge cases, actual delivery behavior, timeouts. For those, you don't need the *whole* stack — you need the service under test, the message queue, and typed clients driving it. Stand up just those, run a focused suite, and keep it small. This is where a running queue and a real generated client earn their keep; everything else stayed in layers 1 and 2. ## The principle Push tests down to the cheapest layer that can catch the bug: - **Logic** → unit-test the service class directly (no infra). - **Cross-service contracts** → typed clients as seams, with fakes that can't drift. - **Wire/delivery behavior** → a minimal integration slice, not the full fleet. You end up with a fast, reliable majority of tests and a small, deliberate set of heavy ones — instead of one giant, flaky end-to-end suite. To structure services this way from the start, see [Getting Started](https://imqueue.org/get-started/) and the client-generation workflow in the [Tutorial](https://imqueue.org/tutorial/). --- # Internal APIs don't need to be REST Source: https://imqueue.org/blog/internal-apis-dont-need-rest/ Published: 2026-06-27 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) REST won the public web for good reasons: resources, uniform verbs, cacheability, and a shared vocabulary every client understands. But a lot of teams then apply REST to their *internal* service-to-service calls out of habit — and internally, most of what you're doing isn't manipulating resources. It's calling a function in another process. This is a concept post, not a product pitch: when is REST the wrong shape for an internal API, and what fits better? ## The impedance mismatch Internal calls are usually verbs, not nouns. "Calculate this price." "Send this notification." "Reserve this inventory." Forcing those through resource-and-verb REST creates friction: - **Modeling actions as resources.** "Calculate a price" becomes `POST /price-calculations`, inventing a resource that exists only to host an action. - **Status-code semantics.** You map domain outcomes onto HTTP codes, then callers translate them back — is a 404 "no such user" or "route not found"? A 409 "already exists" or something else? - **Untyped by default.** A route returns whatever it returns; the caller re-parses JSON and hopes the shape matches. Type safety is bolted on with generators and annotations. - **Addressing overhead.** Each call needs a host, which drags in discovery and load balancing just so services can find each other. None of this is fatal, but it's a lot of ceremony around "run this function over there and give me the result." ## RPC: model the call as a call For internal traffic, RPC (remote procedure call) semantics fit the intent directly: you call a method, you get a typed result. No resource modeling, no status-code translation layer, no re-parsing. The mental model is "a function that happens to run elsewhere." `@imqueue` is one way to do this in Node.js/TypeScript: a service exposes methods, and callers invoke them through a generated, typed client. ```ts // Instead of POST /price-calculations and parsing a response body: const { total } = await pricing.quote(cart); // a typed method call ``` `quote` returns a typed value or throws a typed error — the same ergonomics as calling a local function, which is what an internal call *is*. Addressing, balancing, and serialization are handled underneath, so there's no host to resolve and no body to hand-parse. ## When REST is still the right call This isn't "REST is bad." It's "match the tool to the boundary": - **Public / third-party APIs** — REST's ubiquity, cacheability, and tooling are real advantages. Keep it at the edge. - **Genuinely resource-oriented interfaces** — CRUD over well-defined resources maps cleanly to REST; don't fight that. - **Browser-facing endpoints** — browsers speak HTTP; that's the front door. The point is just to stop *defaulting* to REST for internal calls that are really function calls. At your edge, REST. Between your services, consider whether a typed RPC call says what you mean with less ceremony. If that resonates, [Getting Started](https://imqueue.org/get-started/) shows the RPC-style model in practice. --- # From monolith to services: a low-risk first extraction Source: https://imqueue.org/blog/monolith-to-services-first-extraction/ Published: 2026-06-24 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) Most microservice regret starts the same way: a team decides to "go microservices," stops feature work, and attempts a big-bang rewrite that takes two quarters and lands late. There's a far safer path — extract *one* capability, leave the rest of the monolith exactly as it is, and iterate only if the first step pays off. Here's how to make that first extraction low-risk. ## Pick the right first capability Don't start with the hardest or most central part of the system. Look for a capability that is: - **Loosely coupled** — it doesn't reach into half the monolith's internals. - **Clearly bounded** — you can describe its inputs and outputs in a sentence. - **Independently valuable** — it scales differently, deploys on a different cadence, or is owned by a distinct team. Classic good first candidates: image/PDF processing, notifications, a recommendation or pricing calculation, search indexing. Bad first candidates: the thing every request touches. ## Define the seam as a typed call The key to a safe extraction is that the monolith shouldn't know or care whether the capability moved. So define the seam as a **typed service call**, not as scattered HTTP requests. With `@imqueue`, the extracted capability becomes a service: ```ts import { IMQService, expose } from '@imqueue/rpc'; export class PricingService extends IMQService { /** * Calculates the total price for a cart * @param {{ items: string[]; coupon?: string }} cart * @return {Promise<{ total: number; currency: string }>} */ @expose() public async quote(cart: { items: string[]; coupon?: string }): Promise<{ total: number; currency: string }> { // ...the logic you're lifting out of the monolith return { total: 4200, currency: 'USD' }; } } ``` The monolith generates a typed client and calls it like a local function: ```ts const pricing = new PricingServiceClient(); await pricing.start(); const { total } = await pricing.quote(cart); ``` Because the client is typed and generated from the service, the monolith's call site is checked at compile time — the seam is as safe as an in-process function call. ## Keep the blast radius small A few disciplines make the first step reversible: - **Move behavior, not the world.** Lift the logic and its immediate data access; don't try to untangle everything it transitively touches on day one. - **Run it beside the monolith.** No need for a platform overhaul — start the service, point the monolith's generated client at it, done. There's no load balancer or discovery layer to stand up first (the queue handles addressing and balancing). - **Have an escape hatch.** Because the seam is a single typed call, falling back to the in-monolith implementation is a small change, not a rollback of an architecture. ## Then, and only then, iterate Ship the one extraction. Watch it in production. If it made that capability easier to scale, deploy, and own — extract the next one, using the same pattern. If it didn't, you've spent days, not quarters, and the monolith is intact. That's the whole advantage of incremental: every step is small, typed, and reversible. To build that first service and generate the client the monolith calls, start with [Getting Started](https://imqueue.org/get-started/); the [Tutorial](https://imqueue.org/tutorial/) shows several services coming together the same way. --- # Redis as a message bus: patterns beyond pub/sub Source: https://imqueue.org/blog/redis-message-bus-patterns/ Published: 2026-06-22 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) Redis is often introduced as "a cache with pub/sub," and many teams never look past those two features. But Redis has a set of primitives that make it a genuinely capable message bus — and understanding them helps you reason about what tools like `@imqueue` are doing under the hood. Here's a practical tour. ## Redis pub/sub: simple, and fire-and-forget Redis pub/sub is the obvious starting point: publishers send to a channel, subscribers receive. It's great for broadcast — invalidate a cache everywhere, notify all instances of a config change. Its limitation is that it's **fire-and-forget**. If no subscriber is connected when a message is published, that message is gone; there's no buffering and no delivery guarantee. That's fine for broadcasts you can afford to miss, and wrong for work you can't lose. ## Redis lists as competing-consumer work queues The more interesting Redis pattern uses **lists** as queues. A producer `LPUSH`es a message; a consumer `RPOP`s it. Run several consumers against the same list and each message goes to exactly one of them — a competing-consumers work queue, with load balancing for free. Naively, consumers would have to poll (`RPOP` in a loop), burning CPU and adding latency. Redis solves that with **blocking** variants: `BRPOP` / `BLMOVE` let a consumer block until a message arrives, so there's no polling and no idle cost. This is the backbone of most Redis-based queues. ## Making Redis list delivery reliable Plain `RPOP` has a gap: if a consumer pops a message and then crashes before finishing, the message is lost. The fix is to move the message to a per-consumer "processing" list atomically as you take it, using `LMOVE` / `BLMOVE` (available in Redis 6.2+). If the consumer dies, the message is still sitting in its processing list and can be rescheduled; only on success do you remove it. This is exactly the trade-off `@imqueue` exposes as its two delivery modes: a fast *unreliable* mode (a lost consumer means a lost message) and a *safe/guaranteed* mode (a grabbed-then-lost message is rescheduled), the latter built on those atomic list moves. Guaranteed delivery costs some throughput; you choose per workload. ## Redis Streams Redis 5 added **Streams**, an append-only log with consumer groups, message IDs, and acknowledgements — closer in spirit to Kafka than to a simple list. Streams are powerful when you need replay, multiple independent consumer groups over the same data, or a durable event log. They're also more to manage. For straightforward work-queue and RPC routing, lists with blocking moves are simpler and lighter; for event-sourcing-style needs, Streams earn their complexity. ## Redis keyspace notifications One more Redis primitive worth knowing: Redis can emit **keyspace notification** events when keys change. Keyspace events let a system react to expirations and mutations without polling. It's off or partial by default on some managed Redis offerings (for example, you may need to set `notify-keyspace-events Ex` on AWS ElastiCache). If you build on a library that relies on these events, that's a configuration line to remember. ## Where @imqueue sits `@imqueue/core` is essentially a well-engineered application of these patterns: blocking list operations for no-poll delivery, atomic moves for optional guaranteed delivery, round-robin balancing across multiple Redis instances for horizontal scale, and no timers so idle queues cost nothing. On top of that, `@imqueue/rpc` adds the typed request/response layer. You could assemble these patterns yourself — but the value of a framework is that the tricky parts (reliability, scaling, no-poll efficiency) are already handled and tested. If you want to see the RPC layer built on these foundations, start with [Getting Started](https://imqueue.org/get-started/), or read the [Messaging API overview](https://imqueue.org/api/) for how the queue itself is exposed. --- # @imqueue vs Moleculer: two takes on Node.js microservices Source: https://imqueue.org/blog/imqueue-vs-moleculer/ Published: 2026-06-19 Updated: 2026-07-29 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) **Moleculer is a batteries-included microservices framework; `@imqueue` is a focused, TypeScript-first RPC layer.** Moleculer gives you a service broker with pluggable transporters, built-in discovery, load-balancing strategies, circuit breakers and an API gateway. `@imqueue` gives you self-describing service classes over a message queue and a typed client generated from them. Moleculer offers more out of the box; `@imqueue` offers less to configure and stronger static typing. Both are actively maintained, so this is a fit question, not a health one. (Details about Moleculer reflect its documented behavior at the time of writing; check its docs for the current state.) ## Transport and topology Moleculer abstracts the transporter: pick NATS, Redis, AMQP, Kafka or TCP and the broker handles the rest, including a registry so nodes discover each other and a choice of balancing strategies. That flexibility is genuinely useful if you already run NATS, or expect to switch buses later. `@imqueue` takes a simpler route: a service consumes from its own named queue, and multiple instances compete on that queue — so you get balancing without a balancer and without a discovery layer. The queue name is the address. Fewer moving parts to run, at the cost of the transport-and-registry flexibility Moleculer offers. It is Redis only: `vendor` defaults to `'Redis'` and is currently the only supported value, with `IMessageQueue` as the documented seam for another adapter. ## The same service, both ways Moleculer services are broker-registered definitions with actions, called by name: ```javascript // service broker.createService({ name: 'user', actions: { get(ctx) { return this.findById(ctx.params.id); }, }, }); // caller — 'user.get' is a runtime string; params validated at runtime const user = await broker.call('user.get', { id: '42' }); ``` `@imqueue` services are classes, and the client is generated from them: ```typescript import { IMQService, expose } from '@imqueue/rpc'; export class User extends IMQService { /** * Returns a user by id * * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { return { id, name: 'Jane Doe' }; } } ``` ```bash imq client generate User ./src/clients ``` ```typescript import { user } from './clients/index.js'; const client = new user.UserClient({ callTimeout: 5000 }); await client.start(); const found = await client.get('42'); // signature came from the service ``` The difference isn't decorators versus objects — it's `broker.call('user.get', …)` versus `client.get(…)`. One is a string the compiler can't check; the other is a method whose signature was derived from the service. Rename the action in Moleculer and callers keep compiling until something fails at runtime; rename it in `@imqueue`, regenerate, and every call site breaks the build. ## Typing model: Moleculer's runtime validation vs `@imqueue`'s generated types The typing model is the sharpest difference between Moleculer and `@imqueue`, and worth being precise about. Moleculer is JavaScript-first and ships official typings; action parameters are typically validated at runtime with a schema validator, which gives you real runtime safety and good error messages. What it doesn't give you is a static contract across the call boundary — that's something you assemble. `@imqueue` is TypeScript-first by design. The trade is that JSDoc is load-bearing rather than optional: it is the *only* type source the generator reads, so an unannotated parameter silently becomes `any`, the `@param` count must match real arity, and consuming projects must compile with `removeComments: false`. Cheap once you know it; confusing if you don't. Neither model catches a *running* peer drifting from the client you generated last month — that's what [versioning your service contracts](https://imqueue.org/blog/versioning-microservices-without-breaking-callers/) is for either way. ## Resilience features, and one thing not to assume Moleculer ships a large surface as first-class, configured features: circuit breakers, bulkheads, retries, timeouts, fallbacks, request tracing, metrics, caching, an API gateway and mixins. If you want those without assembling them, that's a real advantage. `@imqueue` keeps the core small — reliable queueing, RPC, typed client generation, delayed messages, optional caching and locking decorators — and leans on `@imqueue/cli` for scaffolding, CI wiring and fleet management. Some resilience comes free from the transport rather than from features: if a callee is down the request waits on its queue instead of failing, so there's less need for a breaker on that particular failure mode, and services can start in any order. One thing to get right, because it's the easiest wrong assumption to make: **`@imqueue`'s `@lock()` decorator is in-process only.** It coalesces concurrent identical calls within one process. Separate processes, cluster workers and service replicas each keep their own locks and will run the guarded code concurrently. If you need mutual exclusion across replicas, use a Redis- or database-backed lock. Don't read `@lock()` as a distributed primitive. Similarly, safe delivery is narrower than it sounds: it re-queues a message a dying worker never *started*, and delivery is **at-least-once** in either mode, so exposed methods should be idempotent. Nothing in the framework drains in-flight work on shutdown — see [graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) and [what guaranteed delivery costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/). ## Licensing: Moleculer's MIT vs `@imqueue`'s dual GPL/commercial Licensing deserves more than a footnote, because for a lot of teams it settles the Moleculer-or-`@imqueue` question before any technical comparison. **Moleculer is MIT.** Use it in anything, including closed-source commercial products, with no obligations beyond attribution. **`@imqueue` is GPL-3.0**, with a [commercial license](https://imqueue.org/license/) for shipping inside closed-source products. If you're building an internal system, GPL-3.0 is usually a non-issue. If you're shipping a product your customers install, or your legal team has a blanket policy against copyleft, that's a real constraint — and one worth resolving early rather than after you've built on it. Neither model is better; they reflect different funding intentions. But it's the difference most likely to be decisive, so it belongs above the feature table, not below it. ## Where Moleculer is the better choice - **Your transport isn't Redis** — NATS, Kafka and AMQP are first-class there. - **You need MIT licensing** for a closed-source product and don't want a commercial agreement. - **You want resilience features configured, not assembled** — breakers, bulkheads, retries, tracing, gateway. - **Your codebase is JavaScript**, or TypeScript is secondary. - **You want a bigger ecosystem** — more mixins, more integrations, more people who've hit your problem. - **You need distributed coordination primitives.** `@imqueue` deliberately doesn't provide them. ## Where @imqueue's model costs you - **Redis only**, as above. - **JSDoc is mandatory** and `removeComments: false` is required in consumers. - **Client generation needs the service running**, which is a real step in CI and on a fresh checkout. - **`callTimeout` is unset by default** — a call to a service that never answers stays pending forever. - **No rest or spread parameters** on exposed methods; pass an array. - **At-least-once delivery, no drain, no distributed locks**, no streaming. - **GPL-3.0** unless you buy a commercial license. ## Quick comparison | | @imqueue | Moleculer | |---|---|---| | Primary language | TypeScript-first | JavaScript-first (official typings) | | Transport | Message queue (Redis only) | Pluggable (NATS, Redis, Kafka, AMQP, TCP, …) | | Addressing | Queue name = service class name | Broker registry + action names | | Service discovery | Not needed | Built-in registry/discovery | | Load balancing | Competing consumers on a queue | Built-in strategies | | Typed clients | Generated from the service | Assemble yourself / runtime validation | | Contract drift caught | At regeneration, as compile errors | At runtime | | Resilience | Mostly from the transport | Breakers, bulkheads, retries, fallbacks | | Distributed locks | No (`@lock()` is in-process) | — | | Feature breadth | Focused core | Broad (breakers, gateway, metrics, …) | | Licence | GPL-3.0 / commercial | MIT | ## How to choose - **Choose Moleculer** if you want a broad framework with many transporters, built-in resilience features and a permissive MIT licence, and you're comfortable assembling type safety yourself. - **Choose @imqueue** if your services are TypeScript, you want generated typed clients and minimal infrastructure in the request path, and the GPL-3.0 or commercial model works for you. They're not better or worse — they're aimed at different priorities. ## Frequently asked questions about @imqueue and Moleculer ### Is Moleculer still maintained? Yes. It's actively developed and widely used; nothing in this comparison depends on it being stale. ### Which is better for a TypeScript codebase? `@imqueue`, on the specific axis of static typing across service boundaries, because the client is generated from the service rather than declared alongside it. Moleculer works fine in TypeScript — you just don't get a compile-time contract between caller and action for free. ### Can I use Moleculer's transporters with @imqueue? No. `@imqueue` supports Redis today; `IMessageQueue` is the documented interface if you want to implement another adapter. ### Does @imqueue have circuit breakers? No. Some of that need is absorbed by the transport — a request to a down service waits on the queue rather than failing — but there's no breaker, bulkhead or retry policy to configure. If you want those as features, Moleculer has them. ### Is the GPL-3.0 licence a problem for commercial use? It depends entirely on whether you distribute your product. Internal systems are usually unaffected; shipping closed-source software to customers needs the commercial licence. Resolve it with your legal team before you build, not after. ### Can I migrate from Moleculer to @imqueue incrementally? In principle yes, service by service, since both can sit on Redis and a gateway can front either. Expect the work to be in the typing model and the loss of Moleculer's built-in resilience features, not in the transport. --- If typed, low-infrastructure, queue-based RPC is what you're after, start with [**Getting Started**](https://imqueue.org/get-started/), or read [@imqueue vs NestJS](https://imqueue.org/blog/imqueue-vs-nestjs/) for the other framework comparison and [gRPC vs message-queue RPC](https://imqueue.org/blog/grpc-vs-message-queue-rpc/) for the transport one. Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # @imqueue vs NestJS microservices: framework vs transport Source: https://imqueue.org/blog/imqueue-vs-nestjs/ Published: 2026-06-17 Updated: 2026-07-29 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) **NestJS and `@imqueue` sit at different layers, so "which is better" is the wrong question.** NestJS is a full application framework whose microservices module can send messages over a pluggable transporter. `@imqueue` is a focused RPC layer: self-describing service classes over a message queue, with typed clients generated from them. Understanding the layering is more useful than declaring a winner — and in a lot of systems the answer is both. (NestJS details reflect its documented behavior at the time of writing.) ## Different layers of the stack **NestJS** is a full application framework: dependency injection, modules, controllers, guards, interceptors, an opinionated structure for the whole app. Its microservices module lets a Nest app send and receive messages over a transporter — Redis, NATS, Kafka, RabbitMQ, MQTT, gRPC, TCP — using decorators like `@MessagePattern()` to bind handlers to message patterns. **@imqueue** is not an application framework. It's a focused RPC layer: service classes extending `IMQService`, with typed clients generated from the services. It has no opinions about DI, HTTP controllers, or app structure — it's the piece that moves typed calls between services. So the honest framing is: NestJS is *how you might build a whole service*; `@imqueue` is *how services call each other*. ## The NestJS vs `@imqueue` typing difference, in code Typing across the call boundary is the substantive difference between the two, and it's easier to see than to describe. With the NestJS microservices module, a caller uses a `ClientProxy` and sends to a message pattern. The pattern is a string or object, and the payload and response types are generics you supply: ```typescript // handler side @MessagePattern({ cmd: 'user.get' }) getUser(id: string) { return this.users.findById(id); } // caller side — the types are an assertion, not a derivation const user = await firstValueFrom( this.client.send({ cmd: 'user.get' }, '42'), ); ``` That's clean and decorator-driven, but nothing checks that `{ cmd: 'user.get' }` still exists, or that `User` still matches what the handler returns. The pattern is a runtime string; the types are a promise you make to the compiler on both sides independently. With `@imqueue`, the client is generated *from* the service, so there's no promise to keep in sync: ```typescript import { IMQService, expose } from '@imqueue/rpc'; export class User extends IMQService { /** * Returns a user by id * * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { return { id, name: 'Jane Doe' }; } } ``` ```bash imq client generate User ./src/clients ``` ```typescript import { user } from './clients/index.js'; const client = new user.UserClient({ callTimeout: 5000 }); await client.start(); const found = await client.get('42'); // signature came from the service ``` Change `get`'s signature, regenerate, and every mismatched call site becomes a compile error. There's no pattern string to typo and no response generic to get wrong — but note the JSDoc block isn't decoration: it's the *only* type source the generator reads, so an unannotated parameter silently becomes `any`. ## Addressing and routing A second real difference, easy to miss. NestJS message patterns are an application-level routing key you design: `{ cmd: 'user.get' }`, `'user.created'`, whatever convention you settle on. You own the namespace, and you own keeping it coherent as the system grows. `@imqueue` has no routing keys, because the **queue name is the address** and it's the service class name. There's no service discovery to configure and no load balancer to wire up — instances of a service compete for messages on its queue, so work distributes across replicas by consumption. That's a smaller surface to design, and a smaller surface to get wrong; it's also less flexible if you *want* content-based routing, which `@imqueue` doesn't do. If that model is new to you, [do Node.js backends even need service discovery?](https://imqueue.org/blog/do-nodejs-backends-need-service-discovery/) and [load balancing without a load balancer](https://imqueue.org/blog/load-balancing-microservices-without-a-load-balancer/) cover the consequences. ## Transport choice NestJS's pluggable transporters are a genuine advantage worth stating plainly. If you need Kafka because the rest of your organisation is on Kafka, or NATS because you want its semantics, NestJS gets you there with a configuration change and the same `@MessagePattern()` handlers. `@imqueue` ships one transport: Redis. `vendor` defaults to `'Redis'` and is the only supported value, though the `IMessageQueue` interface is the documented seam if you ever implement another. If your transport is already decided and it isn't Redis, that decides this comparison on its own. ## Feature scope and structure NestJS gives you a great deal of structure and a huge ecosystem — validation pipes, config, testing utilities, an enormous plugin catalog. If you want a framework to build entire services in, that's its job, and `@imqueue` doesn't compete with it. `@imqueue` keeps its footprint small and pairs with [`@imqueue/cli`](https://imqueue.org/cli/) for scaffolding services, wiring VCS/CI/registry providers, generating clients, and running a local fleet. You bring your own app structure. ## Where NestJS is the better choice - **You want one framework to build whole services in**, with DI, guards, interceptors and a plugin for most things. - **Your transport isn't Redis** — Kafka, NATS, RabbitMQ and friends are first-class there and absent here. - **You need content-based routing** or event patterns richer than "one queue per service". - **You're already on Nest.** The microservices module is a small addition to a codebase that already has the framework's shape. - **You want a large hiring pool and a big ecosystem.** Nest is far more widely known. ## Where @imqueue's model costs you - **Redis only**, as above. - **JSDoc is mandatory** for exposed methods, and `removeComments: false` is required in consuming projects or the generator sees nothing. - **Client generation needs the service running**, which is a real step in CI and in a fresh checkout. - **`callTimeout` is unset by default** — a call to a service that never answers stays pending forever. Set it. - **Delivery is at-least-once** in both modes, so exposed methods should be idempotent. Safe delivery re-queues a message a dying worker never *started*; it doesn't protect work already in flight, and draining is on you. [Graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) works through what that actually takes. - **No streaming**, and no built-in circuit breakers, gateway or metrics breadth — the core is deliberately narrow. ## Can you use them together? Largely, yes — they're not mutually exclusive, and this is a reasonable architecture: ``` HTTP ──▶ NestJS edge app ──@imqueue──▶ user, billing, search… ``` Build a service's internals however you like — including with Nest — and use `@imqueue` as the typed transport between backend services, while a NestJS app handles the HTTP edge. Nest's DI doesn't object to a generated client being constructed in a provider; it's a normal class. The main thing to decide deliberately is which tool *owns* service-to-service calls, so you aren't running two RPC mechanisms over the same traffic. Running Nest's Redis transporter *and* `@imqueue` side by side over the same Redis is the configuration most likely to confuse everyone six months later. ## Quick comparison | | @imqueue | NestJS (microservices) | |---|---|---| | Layer | RPC transport + typed clients | Full application framework | | Transport | Message queue (Redis only) | Pluggable (Redis, NATS, Kafka, RabbitMQ, gRPC, …) | | Addressing | Queue name = service class name | Message patterns you design | | Inter-service typing | Client generated from the service | Generics you assert on both sides | | Contract drift caught | At regeneration, as compile errors | Not caught by the compiler | | App structure | Unopinionated | Opinionated (DI, modules, controllers) | | Service discovery | None needed | None needed (broker-dependent) | | Scope | Small, focused | Broad ecosystem | ## How to choose - **Choose NestJS** if you want a complete, opinionated framework to build services in, with a large ecosystem and a choice of transports, and you're happy managing inter-service contract types by hand. - **Choose @imqueue** if you specifically want typed, low-ceremony RPC between services, on Redis, without a framework dictating your app structure. - **Consider both** if you like Nest for building a service and want `@imqueue` as the typed wire between services. ## Frequently asked questions about @imqueue and NestJS ### Is @imqueue a NestJS alternative? Only for the narrow job of service-to-service RPC. It replaces the microservices module, not the framework — there's no DI container, no HTTP layer and no module system in `@imqueue`. ### Can I use @imqueue inside a NestJS application? Yes. A generated client is an ordinary TypeScript class, so it can be constructed in a Nest provider and injected like anything else. ### Does NestJS's Redis transporter do the same thing as @imqueue? They both move messages over Redis, but the contract differs: Nest routes on patterns you design and types the payload with generics you assert; `@imqueue` addresses by service name and generates the client from the service, so the compiler catches drift after a regenerate. ### Which has better type safety between services? `@imqueue`, for the specific reason that the client is derived from the service rather than declared alongside it. The cost is a generation step and a hard dependency on JSDoc. ### Can @imqueue use Kafka or NATS? Not out of the box — Redis is the only supported transport today, though `IMessageQueue` is the documented interface for an adapter. If your transport is already Kafka, NestJS is the pragmatic answer. ### Is NestJS heavier at runtime? That's the wrong axis to worry about; the meaningful difference is conceptual surface, not milliseconds. Nest asks you to adopt a framework; `@imqueue` asks you to adopt a transport and a codegen step. --- To see `@imqueue`'s model in practice, the [**Tutorial**](https://imqueue.org/tutorial/) builds a multi-service app step by step with a GraphQL gateway in front. For the typing model specifically, see [type-safe service communication in TypeScript](https://imqueue.org/blog/type-safe-service-communication-typescript/). For the other framework comparison — broader feature set, more transports, MIT licence — see [@imqueue vs Moleculer](https://imqueue.org/blog/imqueue-vs-moleculer/), or [how @imqueue compares with tRPC](https://imqueue.org/blog/imqueue-vs-trpc/) if the boundary you care about is client–server. Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # tRPC vs @imqueue: client–server types vs service–service RPC Source: https://imqueue.org/blog/imqueue-vs-trpc/ Published: 2026-06-14 Updated: 2026-07-29 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) **tRPC and `@imqueue` are both typed RPC for TypeScript, but they type different boundaries.** tRPC infers types across a shared TypeScript project, which suits a frontend calling its own backend over HTTP. `@imqueue` generates a client from a running service, which suits backend services calling each other over a message queue. People sometimes ask whether `@imqueue` is "tRPC for microservices" — it's a useful comparison, as long as you keep the two problems distinct. (tRPC details reflect its documented behavior at the time of writing; check their docs for the current API surface.) ## What tRPC is for tRPC shines at the **client–server** boundary — typically a web frontend calling its own backend. You define routers and procedures on the server, and the client infers their types directly from the server's types through a shared TypeScript project. The transport is usually HTTP, with WebSocket support for subscriptions. The magic is that there's no build step: type inference does the work, as long as both sides share the same TypeScript types at compile time. That last condition is the key constraint. tRPC's inference relies on the client being able to `import type` from the server — natural in a monorepo where frontend and backend compile together, and awkward or impossible across independently deployed, independently versioned services. ## What @imqueue is for `@imqueue` targets the **service–service** boundary: backend services calling each other, often deployed separately, over a message queue rather than HTTP. It doesn't rely on a shared compile-time project. Each service is self-describing at runtime, and a typed client is *generated* from the service's description. That generated client can live in a different repository, ship on its own schedule, and be regenerated when the service changes. So the distinction is: - **tRPC:** compile-time type *inference* across a shared project → ideal frontend ↔ backend. - **@imqueue:** runtime self-description → *generated* typed client → ideal service ↔ service. ## The same contract, side by side The difference is clearest in code. With tRPC, the contract is a value on the server whose *type* the client imports: ```typescript // server/src/router.ts import { initTRPC } from '@trpc/server'; import { z } from 'zod'; const t = initTRPC.create(); export const appRouter = t.router({ userById: t.procedure .input(z.string()) .query(({ input }) => getUser(input)), }); export type AppRouter = typeof appRouter; ``` ```typescript // client — reaches into the server's source for its types import type { AppRouter } from '../../server/src/router'; const client = createTRPCClient({ /* links… */ }); const user = await client.userById.query('42'); ``` That relative `import type` is the whole story: it's why tRPC feels frictionless in a monorepo, and why it stops being an option once the two sides live in separate repositories on separate release cadences. With `@imqueue`, the contract is the service class, and the doc-block is load-bearing — JSDoc is the *only* type source the generator reads: ```typescript import { IMQService, expose } from '@imqueue/rpc'; export class User extends IMQService { /** * Returns a user by id * * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { return { id, name: 'Jane Doe' }; } } ``` You generate the client against the **running** service: ```bash imq client generate User ./src/clients ``` and the caller — in any repo — consumes a normal typed class: ```typescript import { user } from './clients/index.js'; const client = new user.UserClient({ callTimeout: 5000 }); await client.start(); const found = await client.get('42'); // typed end to end ``` No `import type` reaching across a project boundary, because the types arrived as generated code. ## What happens in tRPC and `@imqueue` when the contract changes Contract change is where the two models genuinely diverge, and it's the question worth asking of your own architecture. Change a procedure's input in tRPC and the frontend stops compiling **immediately** — same build, same `tsc` run. That tight loop is tRPC's best feature. It works because both sides are one compilation unit, which is also why it can't help you across a deployment boundary: if the backend ships independently, nothing recompiles the caller at the moment the contract moves. Change an exposed method in `@imqueue` and nothing breaks until someone regenerates. Regenerate, and mismatches become compile errors in the caller's own build. That's a deliberate trade: you get a versionable artifact that survives independent deploys, at the cost of a step you have to remember. In practice the loop looks like this, straight from the CLI guide: ```bash imq ctl start -s user -c # bring the service up and wait for readiness imq client generate user ./src/clients imq ctl stop -s user ``` Neither model detects a *running* peer drifting from the client you generated last month — that's what [versioning your service contracts](https://imqueue.org/blog/versioning-microservices-without-breaking-callers/) is for, in either world. ## Transport and coupling tRPC runs over HTTP, which is exactly right for reaching a backend from a browser. `@imqueue` runs over a message queue, which is right for internal traffic — no per-service addressing, competing-consumer load balancing, and natural back-pressure — but not something a browser talks to directly. The queue also changes the delivery contract in a way HTTP doesn't. An HTTP call either returns, errors, or times out. A queue-based call is **at-least-once**: `@imqueue` may deliver the same message twice, so exposed methods should be idempotent. If you're used to reasoning about HTTP request/response, that's the one mental model you actually have to adjust — see [what guaranteed delivery really costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/). ## Where tRPC is the better choice Being honest about this is more useful than a feature count: - **A browser is one of the callers.** Not a contest — the queue isn't reachable from a browser, and `@imqueue` has no story here. - **You want zero build steps.** tRPC's inference needs no generator, no artifact, and no running service. `@imqueue` needs the service up to generate against. - **You need subscriptions or streaming.** tRPC has WebSocket subscriptions; `@imqueue` is request/response over a broker, with no streaming. - **You want validation and typing in one place.** tRPC composes with validators like zod so the runtime check and the static type come from one schema. `@imqueue` validates argument *count* and types from JSDoc, but a schema validator is yours to add. - **Your whole app is one deployable.** If frontend and backend ship together forever, tRPC's constraint costs you nothing and its ergonomics are better. ## Where @imqueue's model costs you Equally honest, and worth knowing before you adopt it: - **JSDoc is mandatory and load-bearing.** Missing type annotations degrade to `any`, and the `@param` count must match real arity or calls fail with `IMQ_RPC_INVALID_ARGS_COUNT`. Consuming projects must compile with `removeComments: false` or the generator has nothing to read. - **No rest or spread parameters** on exposed methods — a documented limitation. Pass an array instead. - **`callTimeout` is unset by default.** A call to a service that never answers stays pending *forever*. Set it explicitly; the docs recommend it for production. - **Decorator order matters.** `@expose()` must sit innermost — closest to the method — when combined with `@lock()`, `@cache` or `@logged()`, or argument validation and the generated signature break. - **Node and TypeScript only, Redis only.** `@imqueue` is not polyglot and ships one transport. [gRPC is the better answer for a mixed-language fleet](https://imqueue.org/blog/grpc-vs-message-queue-rpc/). ## Using tRPC and `@imqueue` together tRPC and `@imqueue` coexist cleanly, and in most systems that's the right answer: tRPC (or plain HTTP, or GraphQL) at the edge for your client apps, `@imqueue` between the services behind it. ``` browser ──tRPC/HTTP──▶ API gateway ──@imqueue/rpc──▶ user, billing, search… ``` The gateway is the only process that speaks both. Everything behind it gets queue semantics — competing consumers, no service discovery, no load balancer — and everything in front of it gets the browser-friendly transport it needs. The [tutorial](https://imqueue.org/tutorial/) builds exactly this shape with a GraphQL gateway, and swaps in a REST one later. The one thing to decide deliberately is which tool *owns* service-to-service calls, so you aren't running two RPC mechanisms over the same traffic. ## Quick comparison | | @imqueue | tRPC | |---|---|---| | Primary boundary | Service ↔ service (backend) | Client ↔ server (often frontend ↔ backend) | | Transport | Message queue (Redis) | HTTP / WebSocket | | How types cross | Client generated from the running service | Compile-time inference via shared project | | Artifact | Committed generated client | None — types are inferred | | Deployment coupling | Independent repos, independent cadence | Best as one compilation unit | | Breaks on change | At regeneration | Immediately, same build | | Delivery | At-least-once; handlers idempotent | HTTP request/response | | Streaming | No | WebSocket subscriptions | | Load balancing | Competing consumers on the queue | Whatever fronts your HTTP server | | Languages | Node.js / TypeScript | TypeScript | ## How to choose - **Use tRPC** to connect a TypeScript frontend to its backend with zero-boilerplate inferred types in a monorepo. - **Use @imqueue** to connect backend services to each other with typed, queue-based RPC when they're deployed and versioned independently. - **Use both** in the same architecture — they're solving different halves of the problem. ## Frequently asked questions about tRPC and @imqueue ### Is @imqueue a drop-in replacement for tRPC? No. tRPC types the client–server boundary and speaks HTTP; `@imqueue` types the service–service boundary and speaks a queue. A browser can't talk to `@imqueue` at all. ### Can I use tRPC between backend services? You can, and people do — it's HTTP underneath. The friction shows up in coupling: the caller needs the callee's TypeScript types at compile time, so independently deployed services end up publishing a types package or living in one repo. That's the constraint `@imqueue` removes by generating a client instead. ### Does @imqueue need a monorepo? No — that's rather the point. The generated client is a committed artifact, so callers can live in separate repositories and regenerate on their own schedule. ### Do I still need code generation with @imqueue? Yes, one command against a running service: `imq client generate [path]`. That's the trade for working across deployment boundaries. See [why generated clients beat hand-written ones](https://imqueue.org/blog/stop-hand-writing-microservice-clients/). ### Which gives better type safety? Both are strong, differently. tRPC's inference can't drift from the server because it *is* the server's types. `@imqueue`'s generated client can drift until you regenerate — but it's a real artifact you can version, review and ship independently. ### Can I use @imqueue with a GraphQL or REST gateway? Yes, and it's the common shape: the gateway speaks HTTP outward and `@imqueue` inward. The [tutorial](https://imqueue.org/tutorial/) builds both variants. --- If the service-to-service half is what you're wrestling with, [**Getting Started**](https://imqueue.org/get-started/) shows the generated-client workflow end to end, and [type-safe service communication in TypeScript](https://imqueue.org/blog/type-safe-service-communication-typescript/) covers the typing model in more depth. Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # @imqueue/job vs BullMQ: Redis job queues compared Source: https://imqueue.org/blog/imqueue-vs-bullmq/ Published: 2026-06-12 Author: Andrii Glushko — Maintainer, @imqueue (https://github.com/creomobile) If you need to run background jobs on Redis in Node.js, [BullMQ](https://docs.bullmq.io/) is the name everyone reaches for. But `@imqueue` also ships a job queue — [`@imqueue/job`](https://github.com/imqueue/job) — built on the same Redis-backed core as the rest of the framework. This is an honest comparison of the two, plus one thing `@imqueue` does that BullMQ doesn't. (BullMQ details reflect its documented behavior at the time of writing.) ## The one-line version **`@imqueue/job` is a deliberately *simple*, safe-by-default job queue; BullMQ is the *feature-rich* one.** Both run on Redis, both do concurrent workers and delayed jobs, and both can retry a failed job. BullMQ adds a much larger job-lifecycle surface (a *declarative* retry/backoff policy with dead-lettering, priorities, repeatable/cron jobs, rate limiting, flows, a dashboard). `@imqueue/job` keeps a tiny footprint and leans on guaranteed delivery being on by default. ## What @imqueue/job gives you `@imqueue/job` is a Redis-backed job queue with a small, focused feature set: - **Guaranteed delivery by default.** Safe delivery is on out of the box, with a per-worker lease TTL (`safeLockTtl`) deciding when a holder counts as dead: a job a dying worker was holding is re-queued for another worker rather than vanishing with the process. The lease covers the hand-off, not your handler — a worker killed three seconds into an `await` still loses that attempt — so *at-least-once* is the honest guarantee, and handlers should be safe to re-run. - **Concurrent workers** on one queue — competing consumers with natural load balancing, no separate balancer. - **Delayed / scheduled jobs** to millisecond granularity: `push(data, { delay })`. - **Job expiration (TTL)** — a job can live forever or expire after a set time. - **Publisher / worker / both** roles, so you can split producers and consumers across processes. - **gzip** payload compression, and it's **TypeScript-first**. - **One dependency** (`@imqueue/core`), event-driven (no polling), low idle cost. ```ts import JobQueue from '@imqueue/job'; new JobQueue({ name: 'Emails' }) .onPop(job => sendEmail(job)) .start() .then(q => q .push('welcome@acme.com') .push('reminder@acme.com', { delay: 60_000 })); // run in 1 minute ``` ## Delayed & scheduled delivery is first-class — everywhere in @imqueue This is worth stating plainly, because it's easy to assume a "simple" queue can't schedule: **delayed delivery is a core capability across the whole framework, not an afterthought.** - In `@imqueue/core`, the fundamental `send(toQueue, message, delay?)` takes a delay, implemented with a dedicated delayed-set scored by due time, promoted by Redis keyspace notifications (with a polling fallback) — a proper scheduler, not a hack. - In `@imqueue/rpc`, an `IMQDelay` value (with `ms`/`s`/`m`/`h`/`d` units) lets you delay *any* remote call — passed after the call-metadata slot, as [delayed and scheduled work without a job system](https://imqueue.org/blog/scheduled-work-without-a-job-system/) spells out. - In `@imqueue/job`, that surfaces as `push(data, { delay })`. So scheduling to the millisecond is built in at every layer. ## Retries and backoff `@imqueue/job` gives you a retry *primitive* rather than a retry policy — the timing is entirely yours: - A handler that **returns a delay in milliseconds** re-runs the same job after that delay — so you shape the backoff (return a growing delay for exponential backoff) and stop by returning nothing or a negative number. Returning `0` isn't a stop: it re-runs immediately, a hot loop. - A handler that **throws** re-schedules with the job's *original* delay — so a job pushed without one is dropped rather than retried. Catch your own errors instead of leaning on a throw. - If a **worker dies during the hand-off**, safe delivery re-queues the job after `safeLockTtl`. ```ts new JobQueue<{ url: string; attempt: number }>({ name: 'Fetch' }) .onPop(async (job) => { try { await fetchAndStore(job.url); } catch (err) { job.attempt += 1; if (job.attempt > 5) { return -1; // negative: stop retrying } return 1000 * 2 ** (job.attempt - 1); // 1s, 2s, 4s, 8s, 16s } }) .start(); ``` So retries and backoff *are* expressible — you just write them. What BullMQ adds on top is a **declarative** policy — a fixed `attempts` cap plus a named backoff strategy — with the job automatically **dead-lettered** once attempts run out. In `@imqueue/job` the counter in the payload above is the only attempt cap there is (re-scheduling re-sends the same envelope, so it carries forward), and parking an exhausted job is your own code. ## Where BullMQ goes further Be fair about this — if you need these, BullMQ is the better fit and `@imqueue/job` isn't trying to compete: - **A declarative retry policy** — `attempts` limit + named backoff strategy + automatic dead-lettering (vs the programmable retries above). - **Priorities** across queued jobs. - **Repeatable / cron jobs** on a recurring schedule. - **Rate limiting** of processing. - **Flows** — parent/child job dependencies. - **Progress reporting, events, and a dashboard** ecosystem (e.g. Bull Board). `@imqueue/job` gives you durable, concurrent, schedulable jobs with almost no surface area; BullMQ gives you a full job-orchestration platform. ## The thing BullMQ doesn't do: typed RPC BullMQ is a job queue, full stop. `@imqueue` is a whole framework on one Redis-backed core, and jobs are just one part of it. The same stack also gives you **typed request/response RPC** via [`@imqueue/rpc`](https://github.com/imqueue/rpc): call another service like a local function and `await` a typed result, with the client generated from the service. So if your system needs *both* "do this later" (jobs) *and* "give me this now" (service-to-service calls), `@imqueue` covers both with one dependency and one mental model. With BullMQ you'd pair it with a separate RPC/HTTP layer for the synchronous half. ## Quick comparison | | @imqueue/job | BullMQ | |---|---|---| | Backing store | Redis | Redis | | Guaranteed delivery | ✅ at-least-once, on by default (re-queue on worker death) | ✅ (stalled-job recovery) | | Concurrent workers | ✅ competing consumers | ✅ | | Delayed / scheduled jobs | ✅ millisecond granularity | ✅ | | Job expiration (TTL) | ✅ | ✅ (retention policies) | | Retries + backoff | ✅ programmable (return a delay to back off; no attempt cap) | ✅ declarative (`attempts` + backoff strategy + dead-letter) | | Priorities | ❌ | ✅ | | Repeatable / cron | ❌ | ✅ | | Rate limiting / flows / dashboard | ❌ | ✅ | | Typed request/response RPC | ✅ (via @imqueue/rpc, same core) | ❌ (jobs only) | | Dependencies | 1 (`@imqueue/core`) | several | ## How to choose - **Reach for BullMQ** when you need the rich job lifecycle — a declarative retry/backoff policy with dead-lettering, priorities, cron/repeatable jobs, rate limiting, flows, or a ready-made dashboard. - **Reach for `@imqueue/job`** when you want a small, dependency-light, safe-by-default job queue with delayed/scheduled jobs — especially if you're already using `@imqueue` for service communication and want one stack for both jobs and RPC. To try the @imqueue side, [Getting Started](https://imqueue.org/get-started/) covers the framework, and [`@imqueue/job`](https://github.com/imqueue/job) has the job-queue API. --- # gRPC vs message-queue RPC for internal Node.js services Source: https://imqueue.org/blog/grpc-vs-message-queue-rpc/ Published: 2026-06-09 Updated: 2026-07-29 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) **gRPC and message-queue RPC solve the same problem — a typed call from one service to another — with opposite defaults.** gRPC dials a host over HTTP/2 with a Protobuf schema as the contract, and leaves addressing, balancing and retries to your infrastructure. Message-queue RPC (the `@imqueue` model) sends to a named queue, derives the contract from the service itself, and gets balancing for free — at the cost of language reach and streaming. For an all-Node.js back-end that trade is worth examining rather than assuming. (gRPC details reflect its documented behavior at the time of writing.) ## What gRPC gives you gRPC is a mature, cross-language RPC system built on HTTP/2 and Protocol Buffers. Its strengths are real: - **Cross-language by design.** Define a `.proto` once, generate clients and servers in Go, Java, Python, Node, Rust and more. If your services aren't all one language, this is the headline feature and nothing here competes with it. - **Efficient binary wire format.** Protobuf is compact and fast to encode and decode. - **Streaming.** First-class client, server and bidirectional streaming over HTTP/2. - **A schema as a contract.** The `.proto` file is an explicit, versioned artifact that isn't tied to any one implementation. - **Deadlines and cancellation.** A deadline travels with the call, and a cancelled call actually signals the server. This matters more than it sounds — see below. ## What gRPC costs you to operate gRPC's strengths come with structure you have to run: - **A separate schema language.** You author and version `.proto` files and run codegen in every build. That's a second source of truth alongside your implementation, and it can drift from both sides. - **Addressing is still your problem.** gRPC calls a host; something has to tell the caller where the service is and balance across instances — DNS, a service mesh, or client-side load balancing. HTTP/2's long-lived connections also interact awkwardly with naive L4 load balancers, which is how you end up with a mesh you didn't plan on. - **It's request-shaped, not buffered.** If a callee is down or slow, the caller feels it immediately; retries, backoff and circuit breakers are yours to add. ## What message-queue RPC changes, in the `@imqueue` model Routing RPC through a queue removes the addressing and balancing problems and drops the schema language, in exchange for narrowing scope: - **No `.proto`, no schema codegen.** The service *is* the contract; a typed client is generated from the running service, and the types come from your TypeScript and JSDoc. - **No discovery or load balancer.** A service reads from its named queue; multiple instances compete on that queue and balance themselves. The queue name is the address. - **Natural back-pressure.** If consumers fall behind, the queue absorbs it instead of failing connections. ## The contract, side by side: `.proto` vs an `@imqueue` service class With gRPC, the contract is a file in a third language: ```protobuf // user.proto — versioned separately from either implementation syntax = "proto3"; service User { rpc Get (GetRequest) returns (GetReply); } message GetRequest { string id = 1; } message GetReply { string id = 1; string name = 2; } ``` You run a generator, get stubs on both sides, and implement against them. The `.proto` is genuinely useful — it's language-neutral, reviewable, and diffable — and it's genuinely a third thing to keep honest. With `@imqueue`, the contract is the service class, and the doc-block is load-bearing: JSDoc is the *only* type source the generator reads. ```typescript import { IMQService, expose } from '@imqueue/rpc'; export class User extends IMQService { /** * Returns a user by id * * @param {string} id - user identifier * @return {Promise<{ id: string; name: string } | null>} */ @expose() public async get(id: string): Promise<{ id: string; name: string } | null> { return { id, name: 'Jane Doe' }; } } ``` ```bash imq client generate User ./src/clients ``` ```typescript import { user } from './clients/index.js'; const client = new user.UserClient({ callTimeout: 5000 }); await client.start(); const found = await client.get('42'); ``` One source of truth instead of two — and the price is that the contract is only expressible in TypeScript, so a Go service can't consume it. ## Failure and timeouts: where gRPC and queue RPC diverge most Failure handling is the part most gRPC comparisons skip, and it is where the two models diverge most sharply in production. ~~~mermaid flowchart TB subgraph g["gRPC — connection-shaped"] direction LR GC[caller] -->|"HTTP/2, deadline travels with the call"| R["DNS / mesh picks an instance"] R --> GS["service instance"] GS -.->|"UNAVAILABLE / DEADLINE_EXCEEDED immediately, and the server is signalled"| GC end subgraph i["@imqueue — buffered"] direction LR IC[caller] -->|"request message"| IQ[("queue 'User'")] IQ --> IS["any free instance"] IS -->|"reply message"| IC IQ -.->|"no consumer: the message waits forever, unless callTimeout is set"| IQ end ~~~ gRPC tells the caller immediately that nobody is listening; `@imqueue` lets the request wait until somebody is. The first makes retry policy possible, the second makes boot order irrelevant — and neither is free. **gRPC is connection-shaped.** A deadline is part of the call and propagates to the server; a cancelled or expired call signals the server so it can stop working. Status codes (`UNAVAILABLE`, `DEADLINE_EXCEEDED`, `RESOURCE_EXHAUSTED`) let a caller tell "the service is down" from "the service said no", which is what makes sensible retry policy possible. If nobody is listening, you find out immediately. **Queue RPC is buffered, and that cuts both ways.** If the callee is down, the request waits in the queue instead of failing — services can even start in any order, because a caller's messages simply wait for a consumer to appear. That's a genuine operational nicety. But it changes three things you must design for: - **Delivery is at-least-once, in both modes.** `@imqueue` may deliver the same message twice, so exposed methods should be idempotent. Safe delivery re-queues a message a dying worker never *started*; it does not protect work already in flight. [What guaranteed delivery really costs](https://imqueue.org/blog/guaranteed-message-delivery-cost/) goes through the mechanics. - **`callTimeout` is unset by default.** With no timeout, a call to a service that never answers stays pending *forever*. Set it explicitly — the docs recommend it for production. - **A timeout is not a cancellation.** `callTimeout` rejects the caller's promise with `IMQ_RPC_CALL_TIMEOUT`. There's no documented signal that reaches the service, which never saw a deadline and keeps working. gRPC's propagating deadlines have no equivalent here, and on long or expensive methods that difference is worth designing around. There's a fourth asymmetry: nothing in the framework drains in-flight work on shutdown. A worker killed mid-handler loses that message either way. [Graceful shutdown and zero-drop deploys](https://imqueue.org/blog/graceful-shutdown-zero-drop-deploys/) shows what it actually takes to close that gap yourself. ## What you actually operate with gRPC vs with `@imqueue` The operational surface is usually the deciding factor, so it is worth comparing honestly: | | gRPC | Queue RPC | |---|---|---| | To route calls | DNS, service mesh, or client-side LB config | Nothing — the queue name is the address | | To balance load | Mesh or client-side policy | Nothing — consumers compete | | To survive a callee restart | Retry policy, circuit breaker | Nothing — messages wait | | New infrastructure | Possibly a mesh and its control plane | Redis (3.2+; 6.2+ for safe delivery) | | Build step | `.proto` codegen in every build | One client generation per contract change | The queue column is shorter, which is the entire pitch — but "Redis" is not nothing. It's a stateful dependency in the request path for every internal call, and its availability becomes your RPC layer's availability. ## gRPC vs `@imqueue` performance, honestly Protobuf over HTTP/2 is hard to beat on pure encoding cost, and a JSON-over-queue design isn't trying to. What `@imqueue` measures on one rig — 22 worker processes, ~1 KB messages, round-trip messages/second **summed across all workers** — is roughly 200,000/s with default delivery and about 120,000/s with safe delivery. Enabling gzip cost ~15% of throughput and cut payload size by about 70%. Those are aggregate figures from a single run on one machine, not per-core numbers and not latency percentiles; [the benchmark post](https://imqueue.org/blog/benchmarking-imqueue-throughput/) states the rig and method. Treat them as a shape, measure your own workload, and note that for most internal services the bottleneck is the handler, not the transport. ## Where gRPC is the better choice - **A polyglot fleet.** Not a contest. `@imqueue` is Node.js and TypeScript only. - **You need streaming.** Queue RPC here is request/response over a broker; there's no streaming. - **You need propagating deadlines and real cancellation** — long-running or expensive calls that must actually stop. - **You want a contract independent of any implementation.** A `.proto` can be reviewed and versioned without reference to the code that serves it. - **Extreme wire efficiency matters**, or you're already running a mesh and the addressing problem is solved. ## Where `@imqueue`'s queue RPC costs you - **Redis only.** `vendor` defaults to `'Redis'` and is currently the only supported value, though `IMessageQueue` is the documented seam for another adapter. - **JSDoc is mandatory.** Missing annotations degrade to `any`, `@param` count must match real arity, and consuming projects must compile with `removeComments: false`. - **No rest or spread parameters** on exposed methods — pass an array. - **Generation needs the service running**, which is a real step in CI and on a fresh checkout. - **At-least-once**, no cancellation, no drain — as above. - **A smaller ecosystem.** No mesh integrations, no interceptor catalogue, far fewer people who have hit your problem before. ## @imqueue vs gRPC at a glance | | @imqueue (queue RPC) | gRPC | |---|---|---| | Contract | The service (TS + JSDoc) | `.proto` schema + codegen | | Sources of truth | One | Two (schema + implementation) | | Languages | Node.js / TypeScript | Many (polyglot) | | Transport | Message queue (Redis) | HTTP/2 | | Addressing | Queue name (no discovery) | Host + discovery/mesh | | Load balancing | Competing consumers | Client-side / mesh | | Callee down | Request waits in the queue | Call fails now | | Delivery | At-least-once; idempotent handlers | Exactly one attempt per call | | Deadlines | Client-side timeout only, no cancellation | Propagating deadline + cancellation | | Streaming | Request/response | Full streaming | | Wire format | JSON (optional gzip) | Protobuf (binary) | ## How to choose between gRPC and `@imqueue` - **Choose gRPC** if your services span multiple languages, you need streaming or real cancellation, or you want an explicit schema contract and don't mind operating discovery and load balancing. - **Choose queue-based RPC with @imqueue** if your back-end is Node.js/TypeScript, you'd rather not maintain a `.proto` or a service mesh, and you want typed clients generated straight from your services — accepting at-least-once delivery and Redis in the path. ## Frequently asked questions about gRPC and @imqueue ### Is @imqueue faster than gRPC? Not on wire efficiency — Protobuf over HTTP/2 is hard to beat there. The published `@imqueue` numbers are aggregate round-trips on one rig, not a head-to-head. For most internal services the handler dominates either way. ### Can I use gRPC and @imqueue in the same system? Yes, and it's a reasonable split: gRPC where you cross a language boundary, queue RPC between your Node services. Decide deliberately which owns a given call path so you don't operate two RPC layers over the same traffic. ### Does queue-based RPC replace a service mesh? For addressing and balancing of these calls, largely yes — the queue name is the address and consumers self-balance. It doesn't replace a mesh's other jobs: mTLS, observability, traffic shaping, policy. ### What happens if the target service is down? The request waits on its queue until a consumer appears, rather than failing immediately. Useful for restarts and ordering-independent boot, but it means "no answer yet" and "nothing will ever answer" look identical to the caller — which is exactly why you set `callTimeout`. ### Do I lose type safety without a .proto? No, but the mechanism differs: types come from your TypeScript and JSDoc, and the client is generated from the running service, so mismatches surface as compile errors after a regenerate. You lose the language-neutral, implementation-independent artifact. ### Is at-least-once delivery a problem? It's a design constraint, not a defect: make exposed methods idempotent. Most read methods already are; writes need a natural key or a dedupe check. --- If the second description fits, [**Getting Started**](https://imqueue.org/get-started/) gets you to a working typed call quickly. For the transport model itself see [RPC over Redis in Node.js](https://imqueue.org/blog/rpc-over-redis-nodejs/), and for the framework comparisons rather than the transport one, [@imqueue vs NestJS](https://imqueue.org/blog/imqueue-vs-nestjs/) and [vs tRPC](https://imqueue.org/blog/imqueue-vs-trpc/). Shipping inside a closed-source product? See [commercial licensing & support](https://imqueue.org/license/). --- # Benchmarking @imqueue: throughput and delivery modes Source: https://imqueue.org/blog/benchmarking-imqueue-throughput/ Published: 2026-06-07 Author: Mykhailo Stadnyk — Creator & maintainer of @imqueue (https://github.com/Mikhus) Performance claims are only useful if you can reproduce them, so this post does two things: it reports figures from a real `@imqueue/core` benchmark run, and it shows you how to run the same benchmark yourself — because the only numbers that matter for your decision are the ones from *your* hardware and *your* message shapes. > A note on honesty: the figures below are from one benchmark run and are **hardware-dependent** — see the exact rig under the table. We deliberately don't print head-to-head numbers against other frameworks here, because a fair cross-framework benchmark has to run on identical hardware, message sizes, and delivery guarantees — see "Comparing fairly" at the end for how to do that yourself. ## The measured figures These come from the benchmark that ships with `@imqueue/core`, run in July 2026 on: - **CPU:** Intel Core Ultra 9 275HX (24 cores) · **RAM:** 61 GB · **OS:** Linux (x64) · **Node.js:** 24.15.0 - **22 worker processes** (cores − 2), each with a dedicated CPU core and Redis pinned to its own core - **~1 KB messages** (1,030-byte JSON payload) Throughput is reported as **round-trip messages/second** — a full send→receive cycle, summed across all workers: | Mode | Round-trip throughput | Notes | |---|---|---| | Unreliable delivery (default) | **~200,000 msg/sec** (196k–208k) | Fastest; a consumer that dies mid-message loses it | | Guaranteed (safe) delivery | **~120,000 msg/sec** (118k–129k) | A message a crashed consumer was holding is rescheduled | | Guaranteed + gzip | ~100,000–105,000 msg/sec | Payload compressed ~1,030 → ~322 bytes (≈70% less traffic) | Three things worth internalizing: 1. **Guaranteed delivery costs throughput, but not dramatically.** Safe mode runs at roughly 60% of unreliable throughput here (about 1.7× slower) — a trade many workloads happily make to never silently lose a message. You choose per queue, so latency-critical paths can stay in the fast mode while critical ones run safe. 2. **gzip trades CPU for traffic, not free speed.** Compression cut each 1 KB message to ~322 bytes on the wire (~70% less Redis traffic) but *lowered* throughput, because encoding/decoding costs CPU. Turn it on when bandwidth is the bottleneck, not when throughput is. 3. **No polling means idle queues cost nothing.** The implementation uses blocking queue operations rather than timers, so throughput is the interesting number — there's no baseline CPU burn when the system is quiet. (These are aggregate round-trip figures across 22 workers pushing hundreds of thousands of messages per run, at average end-to-end delivery times of ~2–4 seconds for the full batch — not per-message latency. Measure percentiles on your own workload; see below.) ## Why the delivery modes differ The fast mode pops a message and hands it to a consumer. If that consumer crashes before finishing, the message is gone. The safe mode atomically *moves* the message into a per-consumer processing list as it's taken (using Redis `LMOVE`/`BLMOVE`, which is why Redis 6.2+ is required); if the consumer dies, the message is still there to be rescheduled. That extra atomic move is the source of the throughput difference. It's a clean, understandable cost model rather than a mysterious tax. ## Running the benchmark yourself The harness ships with the repo. With a local Redis running: ```bash git clone https://github.com/imqueue/core.git cd core npm install npm run benchmark # auto: (cores - 2) workers, 100k msgs each # or drive it directly, e.g. the run above (22 workers, 20k msgs each): node benchmark -c 22 -m 20000 ``` Useful flags: ```bash node benchmark -h # -c, --children number of worker processes to fork # -m, --messages messages sent per child during the test # -d, --delay delay (ms) for delayed-message tests # -z, --gzip enable gzip encoding of messages # -s, --safe use safe (guaranteed) delivery # -t, --message-multiply-times inflate the sample message size ``` A few things the harness does deliberately, so your numbers are meaningful: - **CPU affinity.** Workers are pinned to dedicated cores, and the number of workers is capped at CPU count minus two (one core for the OS/stats collector, one for the local Redis process). On an 8-core machine you can run up to 6 workers; on a 4-core machine, 2. - **Local Redis.** The benchmark measures Redis CPU too, so it expects Redis on localhost. This is Linux-oriented — macOS has no reliable CPU-affinity mechanism and Windows affinity isn't implemented, so results there won't be as predictable. Run it with `-s` and without, and with `-t` to grow the message size, to see how *your* workload behaves — small-and-many versus large-and-few stress very different things. ## Comparing fairly against other tools If you want to compare against Moleculer, a gRPC service, a BullMQ-based approach, or raw HTTP, the only comparison worth publishing is one where you hold everything else constant: - **Same hardware**, same core count, same Redis (or same broker) version. - **Same message size and shape.** 1 KB of JSON is a reasonable baseline; report the size you used. - **Same delivery guarantee.** Comparing @imqueue's *guaranteed* mode against another tool's *fire-and-forget* mode is apples-to-oranges. Match the semantics. - **Same concurrency.** Equal numbers of producers/consumers/connections. - **Report percentiles, not just averages** — p50/p95/p99 latency tells you more than mean throughput for anything user-facing. Build a tiny "ping" service in each contender that echoes a fixed payload, drive it with an identical load generator, and record throughput and latency percentiles. That harness — reproducible and hardware-honest — is worth far more than any number we could print for you. To build the @imqueue side of such a comparison, [Getting Started](https://imqueue.org/get-started/) gets a service and a typed client running quickly, and [`@imqueue/core`](https://github.com/imqueue/core) contains the throughput harness described above. --- # GPL-3.0 open-source license terms Source: https://imqueue.org/license/ Published: 2026-07-23 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) @imqueue is free and open source under the GNU GPL-3.0. A commercial license is available for teams that cannot meet GPL's copyleft terms — for example, shipping @imqueue inside a closed-source product. ## Open source under GPL-3.0 Every @imqueue package — [@imqueue/core](https://www.npmjs.com/package/@imqueue/core), [@imqueue/rpc](https://www.npmjs.com/package/@imqueue/rpc), [@imqueue/cli](https://www.npmjs.com/package/@imqueue/cli) and the rest of the [GitHub org](https://github.com/imqueue) — is published under the [GNU General Public License, version 3.0](https://www.gnu.org/licenses/gpl-3.0.txt) (GPL-3.0). GPL-3.0 is a free-software, copyleft license. It grants you broad rights: - **Use** the software for any purpose, **including commercially**. - **Study** how it works and adapt it to your needs. - **Modify** it and **distribute** your modified versions. There is no fee, no sign-up and no usage cap for open-source use. Install it from npm and build. ## The copyleft condition GPL-3.0's freedoms come with one central obligation: **copyleft**. When you *distribute* (convey) a work that is based on, incorporates or links @imqueue, that combined work must also be licensed under GPL-3.0, and you must make its complete corresponding **source code** available under the same terms. In practice this means: - Building an **open-source** project on @imqueue and releasing it under GPL-3.0 (or a GPL-compatible license) is exactly what the license is for. Nothing extra to do. - **Shipping a closed-source or proprietary product** that includes @imqueue — and *not* releasing that product's source under GPL-3.0 — is **not** permitted by the open-source license. That is where a commercial license comes in. ## When you need a commercial license Consider a [commercial license](https://imqueue.com/license/) if any of the following apply: - You distribute a **proprietary / closed-source** application or appliance that embeds or links @imqueue and you cannot release its source under GPL-3.0. - Your organization's policy or a customer contract **prohibits GPL / copyleft** dependencies in shipped products. - You want a commercial relationship with **warranty terms, an SLA or priority support** behind the framework. The commercial license removes the copyleft obligation for your product and is offered per the terms and tiers on **[imqueue.com/license](https://imqueue.com/license/)** and **[imqueue.com/pricing](https://imqueue.com/pricing/)**. For anything specific to your use case, reach us at **[support@imqueue.com](mailto:support@imqueue.com)**. ## Frequently asked **Can I use @imqueue to build an internal or SaaS back end without a commercial license?** Yes. @imqueue is GPL-3.0 (not AGPL). Running it on your own servers to power a service you operate is not *distribution*, so the copyleft source-sharing obligation is not triggered by SaaS/back-end use. You are free to use the open-source packages for this. A commercial license is still available if you want warranty or support terms. ### Do I owe anything for local development, evaluation or open-source projects? No. Use the open-source packages freely; if your own project is GPL-compatible, you are fully covered by GPL-3.0. ### Which packages does this cover? All of them — the entire @imqueue framework is GPL-3.0, and a single commercial license covers your commercial use across the framework. --- *This page is a plain-language summary, not legal advice. The [full GPL-3.0 text](https://www.gnu.org/licenses/gpl-3.0.txt) and the [GNU GPL FAQ](https://www.gnu.org/licenses/gpl-faq.html) are the authoritative sources; consult a lawyer for your specific situation.* --- # Support Source: https://imqueue.org/support/ Published: 2026-08-02 Author: @imqueue maintainers (https://github.com/imqueue) How to get help with @imqueue: where to ask, which repository to file an issue in, how to report a security problem privately, and what to expect for a response. **Free community support** is available to everyone through the channels below. Teams that need guaranteed response times can add [commercial support](https://imqueue.com/support/) on top. ## Start with the documentation Most questions are answered on this site: - **[Get started](https://imqueue.org/get-started/)** — install and run your first service. - **[Tutorial](https://imqueue.org/tutorial/)** — build a working service and a typed client end to end. - **[CLI manual](https://imqueue.org/cli/)** — every `imq` command and flag. - **[API reference](https://imqueue.org/api/)** — every exported symbol of every documented package. - **[MCP server](https://imqueue.org/mcp/)** — let your AI coding assistant read these docs directly. Using an AI assistant? Point it at **[imqueue.org/llms.txt](https://imqueue.org/llms.txt)** or connect the [MCP server](https://imqueue.org/mcp/installation/) and it can search all of the above for you. ## Ask a question or report a bug Everything is on GitHub under the **[@imqueue organization](https://github.com/imqueue)**, and issues are the right place for both questions and bugs. **File in the repository of the package you are using** — that is where the people who maintain it are looking: | If it concerns | File it in | |---|---| | Typed RPC, services, `@expose()`, generated clients | [imqueue/rpc](https://github.com/imqueue/rpc/issues) | | The Redis-backed queue engine | [imqueue/core](https://github.com/imqueue/core/issues) | | The `imq` CLI, scaffolding, fleet control | [imqueue/cli](https://github.com/imqueue/cli/issues) | | The MCP server, hosted or local | [imqueue/mcp](https://github.com/imqueue/mcp/issues) | | Any other package | its own repository — the `repository` field in its `package.json` | | A documentation page | the repository of the package the page documents | A good report gets a faster answer. Please include: - the **package and version** (`npm ls @imqueue/rpc`), plus your **Node.js version**; - what you expected and what happened instead, with the **actual error and stack**; - the **smallest** code that reproduces it. Prefer email? [support@imqueue.com](mailto:support@imqueue.com) reaches the maintainers directly. Note that GitHub issues are public — do not paste credentials, internal hostnames or customer data into one. ## Reporting a security vulnerability **Please do not open a public issue for a security problem.** Use either private channel: - **GitHub** — *Security → Report a vulnerability* on the affected repository, which opens a private advisory (for example, [imqueue/mcp](https://github.com/imqueue/mcp/security/policy)). - **Email** — [support@imqueue.com](mailto:support@imqueue.com) with the affected package and version, the impact, and steps to reproduce. We aim to acknowledge a report within a few business days and will coordinate a fix and a disclosure timeline with you. ## The hosted MCP endpoint For problems with **`mcp.imqueue.org`** specifically — a tool returning an error, a client failing to connect, the endpoint being unreachable — open an issue in [imqueue/mcp](https://github.com/imqueue/mcp/issues) with the tool name, the arguments you sent and the response you got. It is a free, best-effort service with no uptime commitment. If a build depends on it, install the server locally instead — that is the supported configuration: ```bash npx -y @imqueue/mcp ``` See [installation](https://imqueue.org/mcp/installation/) for your client's config, and the [safety model](https://imqueue.org/mcp/security/) for what each mode can and cannot touch. ## What to expect Community support is **best-effort**, from maintainers who also write the code. There is no SLA and no guaranteed response time: most issues get a first reply within a few days, and a reproducible bug report is far more likely to get a quick one than a question we cannot reproduce. If that is not enough for your situation — production-critical systems, procurement that needs an accountable vendor, a migration you want reviewed — **[commercial support](https://imqueue.com/support/)** adds guaranteed response times, a private channel to the maintainers, prioritized fixes and indemnification. ## Contributing Fixes are welcome, and a pull request is often the fastest route to a resolved issue. See [Contributing](https://imqueue.org/contributing/) for how to propose one and for the contribution terms. Related: [Privacy policy](https://imqueue.org/privacy/) · [Terms of use](https://imqueue.org/terms/) · [License](https://imqueue.org/license/) · [Commercial support](https://imqueue.com/support/) --- # Contributing & contribution terms Source: https://imqueue.org/contributing/ Published: 2026-07-23 Updated: 2026-07-29 Author: @imqueue maintainers (https://github.com/imqueue) Thanks for your interest in improving @imqueue! Contributions of all kinds are welcome — bug fixes, features, tests and documentation. Because @imqueue is dual-licensed, please read the contribution terms before you start. @imqueue is an open-source project. Contributions of all kinds are welcome — bug fixes, features, tests and documentation. This page mirrors the `CONTRIBUTING.md` and **@imqueue Contribution Terms** that ship in every [@imqueue repository](https://github.com/imqueue); the copy in the repository you contribute to is the authoritative one. ## Contribution terms — please read first @imqueue is **dual-licensed**: it is free to everyone under **GPL-3.0**, and it is also offered under **commercial licenses** for closed-source use. To make that sustainable, contributions are accepted under the **@imqueue Contribution Terms** (reproduced in full below). **By opening a pull request — or otherwise contributing — you agree to those terms.** In short: - You **keep the copyright** in your contribution. - Your contribution stays available to everyone under **GPL-3.0**. - You grant the project owner the right to **also license your contribution commercially**, royalty-free — you will not receive a fee for it. **If you do not agree, please do not contribute.** ## How to contribute 1. **Open an issue first** for anything non-trivial, so we can agree on the approach before you invest time. Report issues on the specific package they relate to — e.g. [@imqueue/core](https://github.com/imqueue/core/issues), [@imqueue/rpc](https://github.com/imqueue/rpc/issues), [@imqueue/cli](https://github.com/imqueue/cli/issues). 2. **Fork** the repository and create a topic branch from the default branch (e.g. `fix/redis-reconnect` or `feat/lock-timeout`). 3. **Make your change**, following the existing code style. Keep pull requests focused — one logical change per PR. 4. **Add or update tests** so the change is covered, and make sure the full suite passes locally: ~~~bash npm ci npm test ~~~ 5. **Write clear commit messages** and a descriptive PR title and summary. 6. **Open the pull request** against the default branch and fill in the PR template, including the contribution-terms checkbox. ## Guidelines - Match the existing TypeScript style and formatting already used in the file you are editing. - Keep public API changes documented (doc-blocks / README as appropriate). - Be respectful and constructive in reviews and discussions. ## Reporting security issues Please **do not** open a public issue for security vulnerabilities. Report them privately to the maintainers (see the repository's [security policy](https://github.com/imqueue/imqueue.com/security/policy)) so they can be addressed responsibly. --- ## @imqueue Contribution Terms (v1.0) These terms apply to every contribution to any repository of the **@imqueue** project — the packages published under the `@imqueue` npm scope and any repository in the imqueue GitHub organisation. **By submitting a contribution — opening a pull request, pushing a commit, sending a patch, or otherwise offering work for inclusion — you accept these terms in full. If you do not agree with them, do not contribute.** In these terms, **"Owner"** means Mykhailo Stadnyk, together with his successors, assigns and any entity he controls (including VaryLogic s.r.o.), and **"Contribution"** means any work of authorship — code, documentation or other material — you submit to an @imqueue repository. ## 1. What you grant You grant the Owner a **perpetual, worldwide, irrevocable, royalty-free, non-exclusive, transferable and sublicensable** license to use, reproduce, modify, adapt, publish, distribute and otherwise exploit your Contribution and derivative works of it, **and to license and sub-license it under any terms the Owner chooses — including commercial and proprietary (closed-source) licenses, without any obligation to release source code.** In plain terms: the Owner may include your Contribution in @imqueue and **sell commercial licenses that cover it**, alongside the open-source edition. ## 2. No royalties, fees or compensation The rights above are granted **free of charge**. You **waive** any right to royalties, license fees, revenue share or any other compensation arising from the Owner's use or licensing of your Contribution, including its commercial licensing, and you agree not to seek any such payment now or in the future. ## 3. You keep your copyright You **retain ownership** of the copyright in your Contribution and may use it elsewhere for your own purposes. You are granting the Owner the rights in Section 1 — you are not required to transfer ownership. Your Contribution also remains available to the public under the repository's open-source license (GPL-3.0). ## 4. Patent license You grant the Owner and all downstream recipients a perpetual, worldwide, royalty-free, irrevocable (except as stated) patent license to make, use, sell, offer to sell, import and transfer your Contribution, limited to the patent claims you can license that are necessarily infringed by your Contribution alone or in combination with the project. If you start patent litigation alleging the project or your Contribution infringes your patents, the patent license you received terminates. ## 5. Your promises You confirm that: (a) the Contribution is your own original work, or you have the right to submit it and to grant these rights; (b) if your employer has any rights in your work, you have their permission to contribute, or they have waived those rights; and (c) you have identified any third-party material in your Contribution and its license. ## 6. Moral rights To the maximum extent permitted by applicable law, you waive, or agree not to exercise, any moral rights in your Contribution in a way that would prevent the Owner from exercising the rights in Section 1. (Certain moral rights are inalienable under Slovak/EU law; nothing here purports to transfer those.) ## 7. No obligation; "as is" The Owner is under no obligation to use your Contribution. It is provided **"as is"**, without warranty of any kind. ## 8. Governing law These terms are governed by the laws of the Slovak Republic, without regard to its conflict-of-laws rules. --- **If you do not agree to all of the above, do not contribute to @imqueue.** --- # Privacy policy Source: https://imqueue.org/privacy/ Published: 2026-08-02 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) What imqueue.org and the hosted MCP endpoint at mcp.imqueue.org process, who else can see it, how long it is kept, and how to ask us to delete it. There are no accounts here and nothing to sign up for. **Last updated: 2 August 2026** This policy covers the website **imqueue.org** and the hosted Model Context Protocol endpoint **mcp.imqueue.org**. Commercial licensing and support run on a separate site with its own policy — see [imqueue.com/privacy/](https://imqueue.com/privacy/). @imqueue is an open-source project, not a company — there is no legal entity behind these sites, and so no company name or registration number to give you. **[Mykhailo Stadnyk](https://github.com/Mikhus)**, its creator and owner, runs them and is the **data controller** for everything described here; he is the person to ask about any of it. **[Serhiy Morenko](https://github.com/SerhiyGreench)** is the project's most active maintainer. "We" below means them. Questions about this policy, or a request about your data, go to [support@imqueue.com](mailto:support@imqueue.com) and reach him directly. He is resident in the **Slovak Republic**; a postal address is available on request but is not published here, because without a company it would be a private home address. ## The short version - There is **no account, no login and no payment** anywhere on imqueue.org or mcp.imqueue.org. We never ask you who you are. - We do not sell data, we do not run advertising, and we do not use anything collected here to train machine-learning models. - The website offers **third-party analytics**, one of which records how pages are used — but **nothing loads until you accept it**. Decline, or simply ignore the bar, and no analytics script runs and no cookie is set. [How that works](#cookies-and-how-to-refuse-them). - The **hosted MCP endpoint stores nothing**. It receives your tool inputs, answers, and forgets. Our hosting provider keeps short-lived request logs. - The `@imqueue/*` packages you install from npm contain **no analytics or telemetry code**. ## What we process and why ### Visiting the website Serving a page means our hosting provider (Cloudflare) sees the request: your IP address, user agent, the URL, a timestamp and the response status. That is inherent to how the web works, and it is used to deliver the page, to cache it, and to absorb abuse and denial-of-service traffic. We do not build profiles from it and we have no access to a per-visitor log of it. On top of that the pages *offer* two analytics services — neither of which runs unless you have accepted them: - **Google Analytics 4** — aggregate audience measurement: which pages are read, which links bring people here, roughly which country and which browser. - **Microsoft Clarity** — usage analytics with heatmaps and **session replay**: it records clicks, scrolling and mouse movement so a page that confuses readers can be found and fixed. Clarity **masks text typed into form fields by default and we have not turned that off**, so what you write in the [contact form](#the-contact-form) is not part of a recording — and it never loads at all unless you tick session replay. Neither is required to read the site, and neither is loaded until you say yes — see [cookies](#cookies-and-how-to-refuse-them). ### Automated and AI-agent traffic imqueue.org publishes machine-readable documentation — `/llms.txt`, the per-page `index.md` mirrors and `/api/search-index.json` — which crawlers and AI agents fetch without running any JavaScript, so the browser analytics above never see them. We count those requests **on the server instead**, and deliberately without identifying anyone: the event carries the crawler family (for example `GPTBot`, `ClaudeBot`), which surface was fetched and the response status. **No IP address and no fingerprint is sent, stored or hashed** — the identifier is derived from the crawler family alone, so every request from every GPTBot instance worldwide looks like the same visitor. You can see exactly what is happening for any request you make: ```bash curl -sI -A 'GPTBot/1.2' https://imqueue.org/llms.txt | grep x-agent-analytics ``` ### Using the hosted MCP server `mcp.imqueue.org` is a public, unauthenticated MCP endpoint. It runs the documentation-search and code-scaffolding tools of [`@imqueue/mcp`](https://imqueue.org/mcp/) at the edge. **What it receives** is whatever your AI client sends as tool input: a search query, a documentation URL to fetch, or the service and method names you ask it to scaffold. **What it does with it**: answers the call. Each request is handled by a fresh, stateless instance — there are no sessions, no database and no storage attached to it, so nothing you send is retained by us, and nothing is used to train anything. Our hosting provider records an invocation log for each request (timing, URL, status and any diagnostic output) with the retention that applies to our plan; we do not copy it anywhere else. Because that log exists, and because your prompt reaches a third party the moment your AI client calls any remote tool, **do not put secrets, credentials or personal data into tool inputs**. If that matters for your work, run the server locally instead — `npx -y @imqueue/mcp` keeps everything on your own machine and fetches only public documentation pages. See the [safety model](https://imqueue.org/mcp/security/). ### The npm packages The published `@imqueue/*` packages contain no analytics or telemetry. Nothing phones home. The local MCP server's only outbound network access is to `imqueue.org` for documentation, and its `get_doc` tool is locked to that host in code. npm itself records package downloads; that is npm's processing under [their privacy policy](https://docs.npmjs.com/policies/privacy), not ours, and we only ever see the aggregate counters they publish. ### The contact form The form on [/contact/](https://imqueue.org/contact/) is the one place on imqueue.org where you type anything about yourself. When you submit it we receive: | Field | Required | |---|---| | Your name | yes | | Your email address | yes | | Subject | yes | | Message | yes | | Attachments — up to 3 files, 5 MB total | optional | | The page you submitted from | added automatically | It also carries one hidden field you never see. It is a spam trap: automated submissions fill it in, humans do not, and anything that fills it is silently discarded. **What happens to it.** The message is emailed to [support@imqueue.com](mailto:support@imqueue.com) through our email provider (Resend), with your address as the reply-to so we can answer you. Attachments travel with it. We store it in **no database** — there is no ticketing system, no CRM and no mailing list behind this form. It lives in our mailbox for as long as answering you, and any follow-up, needs. **Why we may process it.** Because you asked us to answer you. Nothing you send here is used for anything else, and you will never be added to a list. Please don't paste credentials, connection strings or customer data into it — the same advice as for tool inputs above, for the same reason. Attachments are limited to images, text files and PDFs. ### Emailing us or opening an issue If you email [support@imqueue.com](mailto:support@imqueue.com) directly, or open a GitHub issue, we process what you send in order to answer it. GitHub issues are **public** — anything you paste into one is visible to everyone, so send anything sensitive by email instead. Security reports have [their own private channel](https://github.com/imqueue/mcp/security/policy). ## Who else sees your data We use these providers. We have no other recipients: no data brokers, no advertising networks, no resale of any kind. | Provider | What they do | What they see | |---|---|---| | [Cloudflare](https://www.cloudflare.com/privacypolicy/) | Hosting, CDN and edge functions | Request metadata — IP address, user agent, URL, timestamp, response status | | [Resend](https://resend.com/legal/privacy-policy) | Delivers the contact form to our mailbox | Everything you type into it, any attachments, and your address as reply-to | | [Google Analytics 4](https://policies.google.com/privacy) | Aggregate audience measurement — **only if you allow it** | Page views, referrer, approximate location, device and browser, cookie id | | [Microsoft Clarity](https://privacy.microsoft.com/privacystatement) | Session replay and heatmaps — **only if you allow it** | Clicks, scrolls, pointer movement and a recording of page interaction | All of them are US-headquartered and may process data outside your country; each relies on its own transfer safeguards, described in the policy linked above. ## Cookies and how to refuse them **Nothing non-essential is set before you agree.** On your first visit a bar at the bottom of the page asks, with two equally-weighted buttons: - **Allow all** — both scripts load and set the cookies below. - **Decline all** — nothing loads and nothing is set. That is also what happens if you ignore the bar entirely, close the tab, or browse with JavaScript disabled: the tags are written into the page in a form the browser will not execute, and only your acceptance converts them into real scripts. - **Customise** — the two are separate consents and you can allow either on its own: | Choice | What it turns on | |---|---| | Usage analytics | Google Analytics — which pages get read, counted in aggregate | | Session replay | Microsoft Clarity — a recording of your clicks, scrolling and pointer movement | Ticking usage analytics does **not** start session replay. Each has its own tag and its own cookies, and withdrawing one leaves the other alone. The choice is remembered in your browser's `localStorage` under `imqueue-consent`, not in a cookie, so a declining visit leaves this site with **no cookies at all**. You are asked once; **"Cookie settings"** in the footer of every page reopens the bar whenever you want to change your mind. Withdrawing consent expires the first-party analytics cookies immediately and reloads the page so nothing keeps running. We set no cookies of our own — no preference cookie, no session cookie, nothing that identifies you between visits. Everything below belongs to the analytics services and appears only after you accept: | Cookie | Set by | Purpose | |---|---|---| | `_ga`, `_ga_*` | Google Analytics | Distinguishes visitors and sessions for aggregate counts | | `_clck`, `_clsk` | Microsoft Clarity | Ties page interactions together into one recorded session | Belt and braces, if you would rather not rely on us: - **Block them in your browser.** Every current browser can block third-party cookies or all cookies for a site; the site works fine without them. - **Google Analytics opt-out** — install the [official opt-out add-on](https://tools.google.com/dlpage/gaoptout), which stops GA4 on every site. - **Microsoft Clarity** — block `clarity.ms` in your browser or a content blocker, and see the [Microsoft privacy statement](https://privacy.microsoft.com/privacystatement) for their own controls. Clarity can also set cookies on its own domain, which our withdrawal cleanup cannot reach — a browser-side block or clear does. - **Any tracker blocker** (uBlock Origin, Privacy Badger, Brave's shields, Safari's protections) already stops both. Declining changes nothing about what the site serves you. There is no reduced version of these docs, no repeated nagging, and no feature behind a yes. ## How long we keep things | What | Kept for | |---|---| | Cloudflare request logs and MCP invocation logs | Short-lived, per our hosting plan's retention; not copied out | | Google Analytics data | Per the property's retention setting, aggregated thereafter | | Microsoft Clarity recordings | Per Clarity's own retention (a small number of months) | | Agent-traffic events | Indefinitely, but they identify a crawler family — never a person | | Contact-form messages and attachments | As long as answering you and any follow-up needs | | Resend delivery logs | Per Resend's own retention | | Emails you send us | As long as needed to answer, then as ordinary business correspondence | | MCP tool inputs | Not retained | ## Your rights If you are in the UK, EU/EEA or another region with comparable law, you have the right to **access** the personal data we hold about you, to have it **corrected** or **erased**, to **restrict** or **object to** its processing, to **portability**, and to **withdraw consent** where processing relies on it. You can also **complain to a data-protection supervisory authority** — either the one for where you live, or ours: the Slovak [Úrad na ochranu osobných údajov](https://dataprotection.gov.sk/), since the controller is resident in the Slovak Republic. In practice there is usually very little to act on: without accounts we hold no profile of you, and the honest answer to a deletion request will often be that we never had anything under your name. Where we do — an email thread, for example — write to [support@imqueue.com](mailto:support@imqueue.com) and we will act on it. For analytics data held by Google or Microsoft, the opt-outs above stop collection at source, and their own privacy dashboards handle deletion. **Withdrawing consent** takes one click: **"Cookie settings"** in the footer, then Decline. It is as easy to take back as it was to give, and refusing costs you nothing. Our legal bases are: **your consent** for the analytics described above (freely given, and withdrawable at any time from the footer); **legitimate interest** in operating and securing a free documentation site and service, which covers the hosting logs and the crawler-family counters that carry no personal data; and — for anything you send us — **your request** that we respond to it. ### If you are in California We do not sell your personal information, and we do not share it for cross-context behavioural advertising — under the CCPA/CPRA definitions of those words, neither happens here, so there is no "Do Not Sell or Share My Personal Information" process to run. There is no advertising on this site, no ad network, no data broker, and no profiling. You still have the right to know what is collected and why (this page), to request deletion or correction, to limit the use of sensitive personal information (we collect none), and not to be discriminated against for exercising any of it — the site behaves identically whether you accept or decline. Declining the analytics bar, or sending a browser Global Privacy Control signal that your tracker blocker enforces, achieves the opt-out. To exercise anything else, email [support@imqueue.com](mailto:support@imqueue.com); we will verify a request only to the extent of matching it to whatever we actually hold, which is usually nothing. ## Children Nothing here is directed at children, and we do not knowingly collect data from anyone under 16. This is developer documentation for a server-side framework. ## Changes to this policy If we change what we process, we update this page and the *Last updated* date above. This page is version-controlled like everything else, so the [site's commit history](https://github.com/imqueue) shows exactly what changed and when. ## Contact Questions about this policy, or about anything on this page: [support@imqueue.com](mailto:support@imqueue.com). Related: [Terms of use](https://imqueue.org/terms/) · [Support](https://imqueue.org/support/) · [MCP safety model](https://imqueue.org/mcp/security/) · [imqueue.com privacy policy](https://imqueue.com/privacy/) --- # Terms of use Source: https://imqueue.org/terms/ Published: 2026-08-02 Updated: 2026-08-03 Author: @imqueue maintainers (https://github.com/imqueue) The terms for using this website and the hosted MCP endpoint at mcp.imqueue.org. These cover the site and the service — the software itself is licensed separately under GPL-3.0. **Last updated: 2 August 2026** These terms apply to the website **imqueue.org** and the hosted Model Context Protocol endpoint **mcp.imqueue.org**. @imqueue is an open-source project rather than a company: both are run by **[Mykhailo Stadnyk](https://github.com/Mikhus)**, its creator and owner, with **[Serhiy Morenko](https://github.com/SerhiyGreench)** as its most active maintainer ("we", "us"). By using either, you accept these terms. If you do not, please do not use them — everything here is optional and free. ## What these terms are not **They are not the software license.** The `@imqueue/*` packages are open source under **GPL-3.0**, and your rights to the code come from that license alone — nothing on this page adds to it or takes anything away. See [the licensing page](https://imqueue.org/license/), or [imqueue.com](https://imqueue.com/license/) if you need a commercial license for closed-source distribution. They are also not a support contract. Community help is best-effort — see [Support](https://imqueue.org/support/). ## Using the website The documentation is here to be read, quoted and linked to. You may quote it with attribution, and you are welcome to point AI coding agents at the machine-readable feeds we publish for exactly that purpose (`/llms.txt`, the per-page `index.md` mirrors, `/api/search-index.json`). Wholesale republication of the documentation as your own is not permitted. Text, images and site design are © their authors, all rights reserved except as stated above. Code samples in the documentation are yours to use freely, in any project, under any license. ## Using the hosted MCP endpoint `mcp.imqueue.org` is offered free, with no account and no authentication, as a convenience for trying the [`@imqueue/mcp`](https://imqueue.org/mcp/) server without installing anything. In return we ask for reasonable behaviour: - **Use it at a human pace.** It is sized for interactive use by AI coding agents, not for bulk scraping, load generation or as a backend for your own product. - **Do not attack it** — no attempts to overload it, to break out of it, to probe for vulnerabilities without [reporting them properly](https://github.com/imqueue/mcp/security/policy), or to use it as a step in attacking anyone else. - **Do not send secrets.** Tool inputs travel over the public internet to a third-party edge platform and appear in short-lived hosting logs. See the [privacy policy](https://imqueue.org/privacy/#using-the-hosted-mcp-server) and the [safety model](https://imqueue.org/mcp/security/). We may rate-limit, block or withdraw access at any time to keep the service healthy, and we may change or remove the tools it offers. If you need guarantees, run the server locally — that is the supported way to depend on it: ```bash npx -y @imqueue/mcp ``` ## What the tools produce The scaffolding tools generate **illustrative code** from a template. It is a starting point, not audited production code: read it, test it, and take responsibility for anything you ship. The documentation-search tools return whatever our documentation currently says; documentation can lag behind a release, and the authoritative answer is always the package source. You own whatever you build. We claim no rights over the code these tools generate for you. ## Availability Both the site and the endpoint are provided **as-is and as-available**, with no uptime commitment, no SLA and no promise that either will continue to exist. We run them on a best-effort basis and may take them down, move them or change them without notice. A commercial agreement is the way to get guarantees — see [imqueue.com](https://imqueue.com/support/). ## No warranty To the fullest extent permitted by law, the site, the endpoint and everything they return are provided **without warranty of any kind**, express or implied, including merchantability, fitness for a particular purpose, accuracy and non-infringement. ## Limitation of liability To the fullest extent permitted by law, we are not liable for any indirect, incidental, special, consequential or exemplary damages, nor for lost profits, lost data, or business interruption, arising from your use of the site or the endpoint — including from code produced by the scaffolding tools or decisions made on the strength of the documentation. Nothing here excludes liability that cannot lawfully be excluded. ## Third-party links and services We link to GitHub, npm and other services we do not control. Their terms and privacy policies govern your use of them, not these. ## Changes We may update these terms; the *Last updated* date above moves when we do, and the [commit history](https://github.com/imqueue) shows exactly what changed. Continuing to use the site or the endpoint after a change means you accept it. ## Governing law These terms are governed by the laws of the **Slovak Republic**, where the person who runs these sites is resident, and the Slovak courts have jurisdiction over any dispute arising from them — without prejudice to any mandatory consumer protections available to you where you live, which nothing here can take away. ## Contact [support@imqueue.com](mailto:support@imqueue.com). Related: [Privacy policy](https://imqueue.org/privacy/) · [Support](https://imqueue.org/support/) · [License](https://imqueue.org/license/) · [imqueue.com terms](https://imqueue.com/terms/) --- # API guide Source: https://imqueue.org/api/ The guide below is included from the same source the HTML page renders. The generated per-symbol reference is not in this file — browse it at https://imqueue.org/api/, or resolve a symbol name with https://imqueue.org/api/search-index.json. ## Introduction `core` and `rpc` are the framework spine — the runtime API documented below, and the two packages every @imqueue service is built on. Capability packages such as `pg-pubsub` or `async-logger` layer on top of them, each publishing its own generated reference under `/api/`. `cli` is different again: a rapid-development command-line tool installed globally, documented by a handwritten manual rather than a generated reference. `rpc` re-exports the entire `@imqueue/core` surface, so a single import is enough whether you use only the core features or the RPC features as well. For example, these two imports are equivalent: ~~~typescript import { profile, IMQMode } from '@imqueue/core'; import { profile, IMQMode } from '@imqueue/rpc'; ~~~ Both work because `profile` and `IMQMode` are defined in `core`, which is a dependency of `rpc`. There is exactly one exception: `export *` never forwards a default export, so core's default-exported [IMQ](https://imqueue.org/api/core/latest/core.imq/) factory is **not** available from `rpc` — `import IMQ from '@imqueue/rpc'` yields `undefined`. Import it from `@imqueue/core` directly. This pairing is specific to `core` and `rpc`. The capability packages do not re-export their @imqueue dependencies: `@imqueue/pg-cache` and the rest export only their own surface, so a service using one still imports `@imqueue/rpc` for the framework types. > **Using v3?** @imqueue 3.x ships as native ES modules and requires Node.js > 22.12 or newer. If you're upgrading from 2.x, see the > [Migration from 2.x to 3.x](#migration-from-2-x-to-3-x) section below. ## RPC API The RPC API is a high-level framework for building client–server communication between services using the Remote Procedure Call pattern. If you want the full set of @imqueue features, this is the API to build on. ### Configuration Configuration options are usually passed to a Service or Client constructor. In a real-world setup, the cleanest approach is to map option values from environment variables into the object at instantiation, giving you full flexibility at deployment time. Every service scaffolded from the boilerplate includes a `config.ts` file where these options can be set, for example: ~~~typescript export const serviceOptions: Partial = { cluster: JSON.parse(process.env['REDIS_CLUSTER_CONFIG']), safeDelivery: !!process.env['MQ_SAFE_DELIVERY'], // etc... }; ~~~ This lets a service be reconfigured by the environment it runs in. For local deployment you can define environment variables in your shell (global configuration) or in a `.env` file in the service's root directory (per-service configuration). On platforms such as AWS you can pass variables in from Parameter Store, and so on. #### Generic options (Service and Client) - **host** — Redis server host. Default: `"localhost"`. - **port** — Redis server port. Default: `6379`. - **cluster** — defines a cluster of Redis servers, used instead of the host/port pair when provided. It's an array of Redis host/port pairs; @imqueue automatically distributes messages across the configured cluster nodes. - **prefix** — the Redis key prefix used for @imqueue key/value pairs. Default: `"imq"`. - **logger** — a reference to a logger implementation, used by the whole library. Default: `console`. The implementation must satisfy the [ILogger](https://imqueue.org/api/core/latest/core.ilogger/) interface. - **safeDelivery** — enables or disables safe message delivery. Default: `false` (off). When on, reading a message moves it atomically out of the queue into a worker-owned key instead of popping it outright, so a worker that dies *before it starts* on a message leaves that message behind to be re-queued rather than taking it down with the process. **The guarantee covers that hand-off, not the processing:** the worker key is released as soon as the message reaches the handler, so a worker killed mid-handler loses it exactly as it would with safe delivery off. Delivery is [at-least-once in either mode](https://imqueue.org/api/core/latest/core.imqoptions.safedelivery/), so handlers should be idempotent, and draining in-flight work before exit is up to the application. - **safeDeliveryTtl** — time to live, in milliseconds, for a message leased by a consumer instance — how long it stays in a worker key before the watcher sweeps it back onto the main queue. Default: `5000` (5 seconds). This is a **recovery deadline for an abandoned hand-off, not a processing deadline**: a slow handler is neither interrupted nor re-queued for taking too long, so raising this value extends no protection over long-running work. Note it also sets the maintenance sweep interval that drives `cleanup`, whether or not safe delivery is on. - **useGzip** — enables or disables gzip compression. Default: `false` (off). @imqueue exchanges messages as plain JSON. If your messages are large, compression can be a sensible way to reduce traffic between consumer instances and Redis nodes. It trades worker CPU for bandwidth: on the [published benchmark](https://imqueue.org/blog/benchmarking-imqueue-throughput/) it cost about 15% of throughput and cut a ~1 KB payload by roughly 70%. Both producer and consumer of a queue must use the **same** setting — a mismatch makes deserialization fail and the message is dropped permanently, even under safe delivery. #### Client-only options There are several ways to create and instantiate service clients: 1. Using only pre-generated client source files. 2. Instantiating clients dynamically, with or without generating source files. 3. Implementing clients manually, when you need to. The following options select among these modes: - **compile** — allows the generated client module to be interpreted by JavaScript on the fly. Default: on, so dynamically generated clients work out of the box with no extra configuration. Can be turned off if needed. - **write** — enables or disables persistence of the generated client code. Default: on. - **path** — where the generated client source files are written. Default: `"./src/clients"`. ### Service and Client IMQ is built so that **you focus only on service development** — the client is generated automatically from the service's description. Every @imqueue service is self-describing, so your only responsibilities are writing good descriptions (correct type definitions and doc-blocks) and, of course, implementing the functionality itself. This keeps development simple: you write the service, and the framework handles the rest. If you have special requirements, there's nothing stopping you from implementing clients by hand — but that adds significant work, both to build and, more importantly, to maintain afterwards. @imqueue provides the [IMQService](https://imqueue.org/api/rpc/latest/rpc.imqservice/) and [IMQClient](https://imqueue.org/api/rpc/latest/rpc.imqclient/) abstract base classes for concrete implementations to extend. Each service is treated as a package containing at least one service class. A complex service may consist of several classes, though in microservice terms that's usually best avoided. A minimal, empty service looks like this: ~~~typescript import { IMQService } from '@imqueue/rpc'; export class SomeService extends IMQService { // service implementation goes here } ~~~ A service is an ordinary TypeScript class — it can have private, protected and public methods, and any properties you like. A few rules must be followed to make it work correctly: * Methods that aren't exposed are not accessible remotely. To make a method remotely callable, you **must** expose it with the `@expose()` decorator: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; export class SomeService extends IMQService { @expose() public exposedMethod() { /* implementation... */ } public unexposedMethod() { /* implementation... */ } } ~~~ * Only methods can be exposed. You **cannot** expose class properties. * To run asynchronous setup during service initialization, override the `start()` method: ~~~typescript import { IMessageQueue, IMQService, expose } from '@imqueue/rpc'; export class SomeService extends IMQService { public async start(): Promise { // async setup here — e.g. open a database connection... return super.start(); } } ~~~ * Prefer the injected logger over `console` for debug, info, warning and error output: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; export class SomeService extends IMQService { @expose() public loggedStuff() { this.logger.log('I am a logged string!'); } } ~~~ * Because remote calls send data across the network, all arguments and return values of exposed methods **must be JSON-serializable**. * You **cannot** use the spread operator for exposed-method arguments — it won't work on the generated client (a known limitation). Pass such arguments as an array instead: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; export class SomeService extends IMQService { // INCORRECT — the client for this service will not compile @expose() public incorrectStuff(...args: any[]) { args.forEach((arg) => { // do something with arg... }); } // CORRECT @expose() public correctStuff(args: any[]) { args.forEach((arg) => { // do something with arg... }); } // Also fine — this one isn't exposed private somePrivateStuff(...args: any[]) { args.forEach((arg) => { // do something with arg... }); } } (async () => { // assuming we have a client for the service: const client = new SomeServiceClient(); await client.start(); // we'd like to write this: client.incorrectStuff(1, 2, 3); // but we do this instead — hardly a hardship: client.correctStuff([1, 2, 3]); })(); ~~~ ### The importance of doc-blocks JavaScript and TypeScript offer little reflection tooling, so there's no easy way to recover argument and return-value types at runtime. @imqueue works around this by reading the doc-blocks attached to your class methods to build its service description. This is good practice from every angle: well-written doc-blocks make code more readable and self-documenting, and let you auto-generate API docs. In @imqueue's case they're also **mandatory** — the framework needs them to describe a service and to generate a correctly working client. Here's what you need to know about writing doc-blocks for @imqueue services: * Always use `@param` and `@return` tags with a proper type for every input argument and return value: ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; export class SomeService extends IMQService { /** * Some method description goes here * * @param {string} argOne - description of the first argument * @param {boolean} argTwo - description of the second argument * @param {{name: string, value: number}} [argThree] - description of the third, optional argument * @return {{x: number, y: number}} - description of the return value */ @expose() public someMethod( argOne: string, argTwo: boolean, argThree?: { name: string, value: number }, ): { x: number, y: number } { // do something with the args... return { x: 5, y: 7 }; } } ~~~ * Wrap optional argument names in `[]` in the doc-block — this is what marks them as optional on the generated client. * Use TypeScript type notation in doc-blocks for arguments and return values; it's carried through into the generated client code. * The documented `@param` list is also what the service's argument-count check validates, so it **must** match the method's real arity — a mismatch rejects calls with `IMQ_RPC_INVALID_ARGS_COUNT`. An undocumented type falls back to `any` rather than failing. * Compile the service project with **`removeComments: false`**. The doc-blocks are read from the emitted sources, so stripping comments leaves the generator nothing to work from. ### Complex types @imqueue services support complex types in the data exchanged between a client and a service. A few rules ensure that both sides understand and use them correctly. Define a complex type's interface on the service side using a **class**, not a TypeScript interface. This is because a class exists in the compiled JavaScript, whereas an interface exists only in TypeScript and is gone at runtime. There's a second wrinkle: JavaScript classes don't support bare properties (only getters and setters), so while TypeScript is happy with a plain property definition, @imqueue needs extra metadata to see it at the JavaScript level. That metadata is supplied by the [`@property()`](https://imqueue.org/api/rpc/latest/rpc.property/) decorator: ~~~typescript function property(type: string | Thunk | any, isOptional?: boolean): any ~~~ The type is normally a type-definition string, as in the examples below (`'string'`, `'Address'`, `'Address[]'`). It can also be a constructor, whose `name` is used, or an anonymous [Thunk](https://imqueue.org/api/rpc/latest/rpc.thunk/) returning either — which is what a self- or forward-referencing type needs, since a thunk isn't invoked until the description is first read. The thunk must be anonymous: a *named* function is taken for a constructor and resolves to its own name. Note that `isOptional` is **not** inferred from TypeScript's `?` modifier — pass `true` explicitly, as the examples below do. Since v3.x, each complex-type class **must** also be annotated with the `@classType()` class decorator. @imqueue v3 uses standard (TC39) decorators, under which `@property()` only collects field metadata — the class-level `@classType()` then finalizes and registers that metadata as a named type, so that both the service and the generated client recognise it: ~~~typescript function classType(): any ~~~ Omitting it produces no error — the type is simply missing from the RPC type description, and generated clients then reference an undeclared type. [`@indexed()`](https://imqueue.org/api/rpc/latest/rpc.indexed/) performs the same flush in addition to recording an index signature, so a class carrying `@indexed()` does not also need `@classType()`. Putting it together: ~~~typescript // service-side definition of the type: import { classType, property } from '@imqueue/rpc'; @classType() class UserObject { @property('string') firstName: string; @property('string') lastName: string; @property('string') email: string; @property('string', true) phoneNumber?: string; } ~~~ This compiles, on the client side, to a matching TypeScript interface, so the type can be used for correct type-checking on both sides: ~~~typescript // generated client-side definition of the type interface UserObject { firstName: string; lastName: string; email: string; phoneNumber?: string; } ~~~ The type can then be used in service methods (assuming the definition lives in its own file): ~~~typescript import { IMQService, expose } from '@imqueue/rpc'; import { UserObject } from './types/UserObject'; class UserService extends IMQService { /** * Updates a user record * * @param {UserObject} data - user data fields * @return {Promise} - the saved user, or null on failure */ @expose() public async update(data: UserObject): Promise { // do logic... return data; } } ~~~ This guarantees correct type-checking at the client level. Complex types can nest other complex types. Suppose we extend the user model to be associated with one or more address objects: ~~~typescript import { classType, property } from '@imqueue/rpc'; @classType() class AddressObject { @property('string') country: string; @property('string') city: string; @property('string') address: string; @property('string', true) phoneNumber?: string; } @classType() class UserObject { @property('string') firstName: string; @property('string') lastName: string; @property('string') email: string; @property('AddressObject[]', true) addresses?: AddressObject[]; } ~~~ ### Working with the service description If you ever need to access a service's description metadata, just call `describe()` on a service client: ~~~typescript // assuming this runs in an async context: const client = new UserClient(); await client.start(); console.log(await client.describe()); ~~~ This prints all the metadata about the service's classes, methods and complex types. ### Delayed messaging Delayed messaging with `@imqueue/rpc` is easy: any exposed service method can be called with a delay. This is handy for building scheduling queues. The generator appends two optional trailing parameters to every generated client method — call metadata (of type [IMQMetadata](https://imqueue.org/api/rpc/latest/rpc.imqmetadata/)) first, then the delay (of type [IMQDelay](https://imqueue.org/api/rpc/latest/rpc.imqdelay/)): ~~~typescript import { IMQDelay, IMQMetadata } from '@imqueue/rpc'; import { UserObject, UserClient } from './clients'; const client = new UserClient(); const data: UserObject = { firstName: 'John', lastName: 'Doe', email: 'john@doe.com', }; await client.start(); // run the scheduled work in 1 hour, and handle the result asynchronously // once it completes, without blocking: client .doScheduledStuff( data, undefined, // metadata slot — skipped new IMQDelay(1, 'h'), // delay is always last ) .then((result: any) => client.logger.log(result)); ~~~ Both trailing parameters are optional, and an ordinary call passes neither. When all you want is a delay, skip the metadata slot with `undefined` as above: from **3.4.0** a trailing `undefined` on a delayed call is a placeholder and is never delivered to the service. Pass a real [IMQMetadata](https://imqueue.org/api/rpc/latest/rpc.imqmetadata/) bag there when you have one — tracing context, an audit reason — and the delay still goes last. The one form to avoid is the delay dropped straight into the metadata slot. The client strips these two parameters **by identity, not by position**, so it works at runtime — but it does not type-check without a cast, and the cast is what makes the placeholder rules below observable. Keep the delay last instead. On older versions the placeholder behaves differently. **3.3.1** dropped only one trailing `undefined`, and only when no metadata was passed, so skipping an optional declared argument as well still delivered it — as `null`, which means a defaulted parameter did not fall back to its default. **3.3.0 and earlier** delivered the placeholder itself, and a method whose declared parameters are all required rejected the call with `IMQ_RPC_INVALID_ARGS_COUNT`. An undelayed call drops nothing, on every version. For the patterns this enables — one-shot reminders, sweepers, backoff — see [delayed and scheduled work without a job system](https://imqueue.org/blog/scheduled-work-without-a-job-system/). ### Reading call metadata Whatever a caller puts in the metadata bag travels with the request, and the service reads it without threading it through its own method signatures. `IMQService` binds every in-flight request to the async execution context, so [currentMetadata()](https://imqueue.org/api/rpc/latest/rpc.currentmetadata/) works inside any exposed method — and inside anything that method awaits: ~~~typescript import { IMQService, expose, currentMetadata } from '@imqueue/rpc'; class OrderService extends IMQService { /** * Ships the order with the given identifier * * @param {string} orderId - order identifier * @return {Promise} */ @expose() public async ship(orderId: string): Promise { const meta = currentMetadata(); this.logger.info('shipping', orderId, meta?.reason); } } ~~~ The bag is opaque, and it arrives as plain JSON — read its fields directly rather than expecting an `IMQMetadata` instance. Outside of a request, at start-up for instance, `currentMetadata()` returns `undefined`. Don't declare an `IMQMetadata` parameter on an exposed method to receive it: the generator treats a trailing `IMQMetadata` or `IMQDelay` parameter as its own and strips it from the client it builds. ### Locking Locking is a powerful tool in `@imqueue/rpc` for optimising remote calls. Imagine one of your service processes receives hundreds or thousands of calls to the same method, with the same execution context, in a very short window — and each returns the same result. This happens, for example, when popular content is requested by many users but stays effectively static over that short period. Without help, your back-end runs the same work hundreds or thousands of times a second for no real reason. Locks fix this. When `@lock()` wraps a method, the first matching call acquires an asynchronous lock. Until that call resolves, all other calls with the same signature wait; when it resolves, they're all resolved with the same result. > **These are not distributed locks.** The lock lives in the memory of one Node.js > process, so the coalescing described here happens *per process*. Separate > processes, `multiProcess` cluster workers and service replicas each maintain their > own independent locks and will run the guarded code concurrently — four replicas > under the same burst do the work four times, not once. That is usually still a > large win, but if you need genuine mutual exclusion across replicas, use a Redis- > or database-backed lock. See > [IMQLock](https://imqueue.org/api/rpc/latest/rpc.imqlock/) for the precise scope. For example, if a specific blog post is fetched 100 times over 100 milliseconds, and the database fetch itself takes about 100 milliseconds, the actual logic runs just once — and all 100 waiting clients are resolved with the same value: ~~~typescript import { IMQService, expose, lock } from '@imqueue/rpc'; import { BlogPost } from './types'; class BlogService extends IMQService { /** * Returns the blog post for a given identifier * * @param {string} id - blog post identifier * @return {Promise} - the blog post data */ @lock() @expose() public async fetchPost(id: string): Promise { let data: BlogPost; // fetch the blog post from the database by id... return data; } } ~~~ Meanwhile, concurrent calls for *different* blog post identifiers run under their own contextual locks and resolve their clients with their own values. This offers two advantages: 1. **Lower back-end load** — less logic executed, fewer database calls. 2. **Better average response time** — the first caller waits longest, but the last is served almost instantly, so the average across all callers improves. There is a trade-off. To identify the execution context, the locking mechanism hashes the call's signature, which costs time and CPU. The algorithm is efficient, but its cost grows with the length of the method signature. In some cases you may gain nothing from locking, so weigh it up: for high-load, slow methods it's clearly worth it; when the method is cheaper to run than its signature is to hash, it isn't. Locking isn't limited to a method decorator. IMQ also provides a general-purpose asynchronous lock class, [IMQLock](https://imqueue.org/api/rpc/latest/rpc.imqlock/), that you can use wherever you need it across your back-end. ### Caching Caching is another optimisation tool @imqueue provides. It caches a method's results using a caching adapter (Redis is the default, and currently the only built-in one). You can supply your own adapter by implementing the [ICache](https://imqueue.org/api/rpc/latest/rpc.icache/) and [ICacheConstructor](https://imqueue.org/api/rpc/latest/rpc.icacheconstructor/) interfaces. Out of the box, use the `@cache()` decorator on service methods, or work with the [IMQCache](https://imqueue.org/api/rpc/latest/rpc.imqcache/) registry and the [RedisCache](https://imqueue.org/api/rpc/latest/rpc.rediscache/) engine directly. Typical usage: ~~~typescript import { IMQService, expose, cache } from '@imqueue/rpc'; import { BlogPost } from './types'; class BlogService extends IMQService { /** * Returns the blog post for a given identifier * * @param {string} id - blog post identifier * @return {Promise} - the blog post data */ @cache() @expose() public async fetchPost(id: string): Promise { let data: BlogPost; // do stuff... return data; } } ~~~ Like `@lock()`, `@cache()` works per call signature. All of @imqueue's decorators can be combined on a service method to improve overall performance and stability. Write load tests for your back-end and use them to find the best optimisation strategy for your case. ## Messaging API The Messaging API is the low-level API implementing the Message Queue pattern used for inter-service communication. Reach for it when you need **only** the messaging layer in your code. This API concerns the messaging-engine adapter, its configuration, logging injection and profiling. ### The IMQ factory and adapters The [IMQ](https://imqueue.org/api/core/latest/core.imq/) factory constructs message-queue instances. Currently IMQ ships with a Redis adapter out of the box. Prefer creating instances through the factory rather than constructing a queue class yourself: it picks the right implementation for the options you pass — supplying [cluster](https://imqueue.org/api/core/latest/core.imqoptions.cluster/) or [clusterManagers](https://imqueue.org/api/core/latest/core.imqoptions.clustermanagers/) gets you a [ClusteredRedisQueue](https://imqueue.org/api/core/latest/core.clusteredredisqueue/) instead of a [RedisQueue](https://imqueue.org/api/core/latest/core.redisqueue/), with no change at the call site. `IMQ` is the **default** export of `@imqueue/core`, so import it without braces. Note that `export *` never forwards a default: it is the one part of the core surface `@imqueue/rpc` does not re-export, so import it from `@imqueue/core` directly. Example: ~~~typescript import IMQ from '@imqueue/core'; const mq = IMQ.create('MyMQ', { vendor: 'Redis' }); ~~~ You don't need to specify the vendor — [`'Redis'`](https://imqueue.org/api/core/latest/core.imqoptions.vendor/) is the default, and currently the only supported value; [IMQ.create()](https://imqueue.org/api/core/latest/core.imq.create/) throws a `TypeError` for anything else. The factory builds only the adapters the framework ships with, so a queue of your own is instantiated directly rather than through it: ~~~typescript import { MyMQAdapter } from './path/to/MyMQAdapter.js'; const mq = new MyMQAdapter('MyMQ'); ~~~ Any such adapter must implement the [IMessageQueue](https://imqueue.org/api/core/latest/core.imessagequeue/) interface, extending `EventEmitter` and emitting `'message'` and `'error'` events. The factory performs no I/O, so the queue it returns is not connected — call `start()` on it, or `send()`, which starts the queue implicitly. ### Redis Queue [RedisQueue](https://imqueue.org/api/core/latest/core.redisqueue/) is the core Redis-based message-queue implementation, providing the engine for a single Redis node. ### Clustered Redis Queue [ClusteredRedisQueue](https://imqueue.org/api/core/latest/core.clusteredredisqueue/) extends `RedisQueue` to work across a cluster of Redis nodes, with automatic round-robin load balancing between them. ## Profiling and debugging Profiling and debugging matter throughout the development and ongoing support of any system. @imqueue provides a simple, built-in tool for measuring and debugging service method execution: the `@profile()` decorator. Apply it to the parts of the system you most need to keep an eye on. Profiled timing is reported in **microseconds** by default. Usage: ~~~typescript import { IMQService, expose, profile } from '@imqueue/rpc'; class MonitoredService extends IMQService { @profile() @expose() public exposedStuff() { // call some internals: this.internalStuff(1, 2, 3); // do anything else... } @profile() private internalStuff(...args: any[]) { for (let i = 0; i < 100000; i++) { } } @profile({ enableDebugTime: true }) private forcedTimeProfiling(...args: any[]) { } @profile({ enableDebugArgs: true }) private forcedArgsProfiling(...args: any[]) { } @profile({ enableDebugTime: true, enableDebugArgs: true }) private forcedFullProfiling(...args: any[]) { } } ~~~ Called with no arguments, `@profile()` follows the environment configuration, which can turn profiling on or off. A [ProfileDecoratorOptions](https://imqueue.org/api/core/latest/core.profiledecoratoroptions/) object overrides the environment and forces time and/or argument profiling explicitly — but only for fields passed as real booleans; any other value is ignored and the environment default applies. Whether timing and argument logging are enabled is resolved **once, when the class is defined**, so changing `process.env` later has no effect. We recommend managing profiling state through `.env` files (per service) or by setting the variables globally (for the whole environment). Those variables are: - `IMQ_LOG_TIME=1|0` — enables or disables execution-time profiling. Empty is treated as `0`, the default. - `IMQ_LOG_ARGS=1|0` — enables or disables argument debug logging. Empty is treated as `0`, the default. - `IMQ_LOG_TIME_FORMAT="microseconds"|"milliseconds"|"seconds"` — sets the time format in the debug output. Empty is treated as `"microseconds"`, the default. `@profile()` writes through the `logger` property of **the instance it decorates** — any [ILogger](https://imqueue.org/api/core/latest/core.ilogger/). Inside a service class that property is already there, so the decorator needs no extra setup. It also works on any class method, not just service classes, but then the logger is yours to provide: an instance with no `logger` profiles the method and produces no output at all, with no warning. Static methods are never logged, because the logger is looked up on instances only. ~~~typescript import { profile } from '@imqueue/core'; class SomeClass { // the decorator logs through this property only; // without a logger nothing is ever written public logger = console; @profile() protected someProtectedMethod() { } } ~~~ Note that enabling profiling can slightly reduce overall back-end performance — but it's invaluable for diagnosing and eliminating bottlenecks and slow code paths. ## Migration from 2.x to 3.x Version 3.x is a modernization release of `@imqueue/core` and `@imqueue/rpc`. It moves the packages to native ES modules and standard TypeScript decorators, built with TypeScript 7. The public runtime API is largely the same, but the following changes require attention when upgrading from 2.x. ### ES modules and Node Both packages are now published as **native ES modules** (`"type": "module"`) and require **Node.js 22.12 or newer**. In practice this means: - Import `@imqueue/core` / `@imqueue/rpc` from ESM code; `require()` of these packages is no longer supported. - In your own project, use ESM as well and add the `.js` extension to relative import specifiers (Node's `nodenext` resolution), for example `import { UserObject } from './types/UserObject.js';`. ### Standard decorators and tsconfig @imqueue 3.x uses **standard (TC39) decorators** instead of the legacy experimental implementation. Update your `tsconfig.json` accordingly: ~~~jsonc { "compilerOptions": { // remove these — legacy decorators are no longer used: // "experimentalDecorators": true, // "emitDecoratorMetadata": true, // use a modern target and the standard-decorators metadata lib: "target": "es2024", "lib": ["es2024", "esnext.decorators"], "module": "nodenext", "moduleResolution": "nodenext", // keep this: doc-blocks are the only type source the client // generator reads, so stripping comments leaves it nothing "removeComments": false } } ~~~ ### `@classType()` is now required on complex types Under standard decorators, `@property()` only collects field metadata — it no longer registers the class itself. Every complex type must now be annotated with the new `@classType()` class decorator (see [Complex Types](#complex-types)). Add it to each `@property()`-decorated class: ~~~typescript // 2.x import { property } from '@imqueue/rpc'; class UserObject { @property('string') firstName: string; } // 3.x import { classType, property } from '@imqueue/rpc'; @classType() class UserObject { @property('string') firstName: string; } ~~~ ### Removed helpers A number of general-purpose utilities that these packages exported alongside their real API have been removed. Most had a standard-library equivalent by the time 3.x was cut; the rest were internals that were never meant to be public. Removed from `@imqueue/core` (and therefore from `@imqueue/rpc`, which re-exports it): | Removed export | Replacement | | --- | --- | | `uuid()` | `randomUUID()` from `node:crypto` | | `promisify()` | `promisify` from `node:util` | | `sha1()` | `createHash('sha1')` from `node:crypto` | | `IJson` | `JsonObject` — `IJson` was only ever an alias for it | | `intrand()` | no equivalent — inline your own random-integer helper | | `propertiesOf()` | no equivalent — walk the prototype chain yourself if you need it | | `pack()` / `unpack()` | internal message codec — removed; `useGzip` covers compression on the wire | | `buildOptions()` | internal helper — removed, inline your own option merge | | `copyEventEmitter()` | internal helper — removed | Removed from `@imqueue/rpc`: | Removed export | Replacement | | --- | --- | | `fileExists()` / `mkdir()` / `writeFile()` | `node:fs/promises` | | `osUuid()` | no equivalent — it returned a machine UUID; use `randomUUID()` if a per-process id will do | | `signature()` | internal — the call-signature hash behind `@lock()` and `@cache()` | | `pid()` / `forgetPid()` / `IMQ_PID_DIR` / `IMQ_TMP_DIR` | internal PID-file bookkeeping — removed | | `SIGNALS` | internal — see [IMQOptions.handleSignals](https://imqueue.org/api/core/latest/core.imqoptions.handlesignals/) | For example: ~~~typescript // 2.x import { uuid } from '@imqueue/core'; const id = uuid(); // 3.x import { randomUUID } from 'node:crypto'; const id = randomUUID(); ~~~ The rest of the runtime API — `IMQ`, `RedisQueue`, `ClusteredRedisQueue`, `profile`, `IMQService`, `IMQClient`, and the `@expose()` / `@lock()` / `@cache()` / `@property()` decorators — is unchanged, `@classType()` above being the one addition you must make.