Engineering
From Client Problem to Open Source: Building a RETS Client in TypeScript
May 2026 · 6 minute read · Matt Cassara · Written with AI assistance
A few years ago, I built a real-estate platform for Ben Bay Realty. One part of the work sounded straightforward: fetch listing data from multiple listing services and turn it into something the application could use.
To connect those feeds, I installed a Node.js RETS client written largely in CoffeeScript. It came from a different era of the ecosystem, and its assumptions did not hold across every MLS. The application gradually accumulated callbacks, wrappers, session workarounds, and provider-specific exceptions just to keep data moving.
When I later rebuilt the MLS server, I had a choice: carry those patterns into a modern codebase, keep wrapping the old package, or write the focused client the application actually needed. That work became `@darksquarebishop/rets-client-ts`, an open-source TypeScript package for systems that still depend on RETS.
RETS is legacy, but it is not gone
RETS is an older XML-over-HTTP standard for exchanging real-estate data. RESO deprecated it and stopped certifying RETS implementations in 2018. New integrations should prefer the modern RESO Web API when an MLS offers one.
But existing feeds, contracts, and production systems still depend on RETS. I had two of them in front of me, and replacing the protocol was not a decision I could make from inside the application. The practical goal was to contain the legacy boundary so the rest of the system could be modern and the protocol could be removed more easily later.
Good integration code does not make an old protocol modern. It contains the old protocol so the rest of the system can be modern.
The same standard did not mean the same integration
The two MLS providers both spoke RETS, but they behaved very differently.
One allowed a persistent session across a multi-page sync. The other allowed only one active session for a credential set and expected a login, one operation, and a logout. Overlapping work could produce a 401 until the previous session expired.
One returned listing photos as multipart binary objects. The other exposed photo records containing CDN paths. One organized properties under a single class; the other split them across six. Query conventions, pagination limits, and even field casing varied.
A generic client could not erase those differences without pretending they did not exist. Instead, the library uses small provider profiles for transport behavior such as RETS version, session mode, and retry policy. Resource names, field mappings, class names, and business rules remain in the consuming application.
That boundary matters: the library owns the protocol, while the application owns the meaning of the data.
The failure that shaped the design
The hardest bug was an intermittent combination of sessions, pagination, and truncated responses. A valid search would occasionally fail with an unexpected end-of-XML error because one provider used chunked transfer encoding and sometimes closed the response before the final RETS element arrived. Running the same query again might succeed.
That provider also allowed only one active session per credential set. Retrying inside a contested session was not consistently safe, and fetching pages concurrently made the problem worse. The fix belonged in the protocol client:
- use persistent sessions for providers that support them;
- use per-request sessions where required;
- lock by credentials so restricted sessions cannot overlap;
- retry only the known truncated-XML failure, with a fresh session between attempts.
The default HTTP retry policy remains off. Blindly retrying a data operation can create a different class of problems, so the package adds recovery behavior only where a production failure justified it.
A small API over an awkward protocol
The application-facing API is intentionally ordinary:
import { createClient, providers } from '@darksquarebishop/rets-client-ts';
const client = await createClient({
...providers.gsmls,
loginUrl: process.env.RETS_LOGIN_URL!,
username: process.env.RETS_USERNAME!,
password: process.env.RETS_PASSWORD!,
userAgent: process.env.RETS_USER_AGENT!,
userAgentPassword: process.env.RETS_USER_AGENT_PASSWORD!,
});
for await (const listing of client.searchAll('Property', 'RES', '(LASTMODIFIED=2026-05-01+)', {
limit: 200,
})) {
await saveListing(listing);
}Behind that call, the client handles HTTP Basic or Digest authentication, RETS user-agent authorization, cookies, capability URLs, DMQL searches, streaming XML parsing, pagination, metadata, multipart media, and structured errors.
It does not decide what a listing means to the product, normalize vendor fields into an application schema, determine which properties belong to a brokerage, or choose how photos should be stored. Those decisions stay in the application, where their context is available.
Why TypeScript and streaming mattered
TypeScript alone was not a reason to replace a working library; the integration pressure already existed. But once I was rebuilding the boundary, types made the design explicit. The compiler can distinguish a buffered result from an async stream, describe provider configuration, and expose the shapes of metadata, object responses, and errors without pretending vendor records are more consistent than they are.
The implementation also looks like current Node.js: ESM, async and await, async iterators, a real cookie jar, a streaming SAX parser, and await using for automatic logout.
Streaming is especially important for listing replication. A full sync can include thousands of records, multiple property classes, and media. The client yields records as they arrive, and searchAll handles provider-limited pagination without collecting the entire result set in memory. The buffered search method is a convenience built on top of that model, not the foundation.
Metadata and media stay at the boundary
RETS data is structured but not uniform. Resources, classes, tables, fields, and lookups vary between providers, so metadata discovery lets the application inspect that structure instead of hardcoding every assumption.
Media exposes the same tension. One MLS returned multipart binary photos; another returned records with vendor CDN paths. Hiding both behind an invented universal photo model would have moved product policy into the protocol layer. The client supports the operations each path requires, while the application decides whether to upload, retain, resize, or discard the result.
The useful abstraction was not making different providers look identical. It was giving their differences a clear place to live.
From production constraint to open source
I did not start by deciding to launch a package. I was trying to make a client integration understandable. Once the protocol logic had a clean boundary, keeping it private stopped buying much: the code was no longer specific to Ben Bay Realty.
Publishing it added useful pressure. The public API had to stay smaller than the implementation. Credentials needed to be redacted from logs. Errors needed enough context to help someone unfamiliar with the codebase. The package needed tests, generated types, reproducible builds, and documentation that clearly stated what it did not support.
Open source does not have to mean universal. This package supports the operations and authentication modes I could validate. When it encounters something outside that boundary, I would rather it fail clearly than guess.
The bigger lesson
If I started the integration again, I would model provider capabilities earlier, keep field mapping out of transport code from day one, and add observability sooner. Request IDs, operation names, response metadata, row counts, and credential redaction are essential when an external system fails intermittently.
The broader pattern is simple:
- start with a real production constraint;
- understand the failure modes before generalizing;
- separate protocol behavior from product behavior;
- build the smallest boundary that makes those decisions explicit;
- publish it when the useful part is no longer client-specific.
Not every internal helper deserves to become open source. But reusable infrastructure can emerge when you stay with a specific problem long enough to understand what is actually general about it.