Skip to main content

What travels on the wire, and what happens when it changes?

Most of the time nobody reads this: the generated codecs, DwAppServer and the client agree by construction, because both sides use the declarations in dartway_core_shared (packages/dartway_core_shared/lib/src/protocol/). It matters when something else speaks to the server — a tool, a test, a proxy — and when the framework or the app changes under a build already installed on someone's phone.

Three paths​

DwHttpContract declares them once for both sides:

POST /dw/<WireName> a request or a command; the body is the DTO's JSON
GET /dw/live the WebSocket of live updates
GET /health liveness and database reachability

/dw/ and /health are reserved: a project route under them fails the server's startup (../4-server/routes.md).

A call​

  • POST only. A request is a read, but its parameters are a DTO body, and one method keeps one code path. Any other method is a malformed call (400).
  • The body is the DTO's own JSON — no envelope, no type tag: the path names the type. Content-Type is application/json in UTF-8; anything else is a malformed call.
  • The body limit is DwServerSettings.maxBodyBytes, 1 MiB by default; a handler may set its own maxBodyBytes. A larger body is a malformed call.
  • A header sent twice is a malformed call: which of two tokens or two keys was meant cannot be guessed.
Header
Authorization: Bearer <token>absent for an anonymous call
Dw-Protocol: <dwProtocolVersion>required on every call
Dw-App-Version: <semver>+<build>the app build (DwAppVersion), e.g. 1.4.2+87
Dw-Idempotency-Keyrequired on a command, forbidden on a request; at most 128 characters
Dw-Live-Connectionoptional: the id of the caller's live socket (channels-and-realtime.md)
Dw-Utc-Offsetoptional: the device's UTC offset at the call, whole minutes east of UTC (180, -300, 345), within 18 hours; read by the handler as ctx.callerUtcOffset (handlers). The client reads it from the device on every call; an app that sends none gets null there, one that sends something else a 400
Retry-Afteron a 429 answer, whole seconds

Query parameters exist only for the kinds whose position is not part of their key:

KindParameters
DwPageRequestoffset, pageSize
DwWindowRequestat most one of anchor, before, after (cursors), and pageSize
every other kind, every commandnone — a table's page and pageSize are fields in the body

A parameter the kind does not take, a repeated one or a count not written canonically (07, +7) is a malformed call.

The answer​

The body is always one of five shapes, and the HTTP status is a function of it (refusals-and-statuses.md):

{"status":"ok","result":<encoded by the call's class>,"updates":{"bookings:7":{"SessionBooking":[…]}}}
{"status":"refused","refusal":{"code":"messageTooLong","params":{"max":"4000"},"field":"text"}}
{"status":"unauthenticated"}
{"status":"failed","incidentId":"…"}
{"status":"incompatible","refusal":{"code":"dw.updateRequired"}}
  • result is untagged — its type is the call's — and omitted when null.
  • updates is what the command published, grouped by channel and then by wire name, and omitted when empty. It is the only place a type name stands next to objects; a deletion travels in the DwDeletedObject group as {"type": "SessionBooking", "id": 9}.
  • "replayed": true marks an answer repeated for an idempotency key.
  • A failed answer adds "failure": "malformedCall" or "unknownCall" for the 400 and 404 cases.
  • A refusal's params and field are omitted when empty.

The live socket​

The client opens GET /dw/live?protocol=<version>&app=<semver+build> — query parameters, because a browser cannot set headers on a WebSocket upgrade. Calls never travel here; the socket carries only what HTTP cannot:

← {"k":"hello","connection":"<id>"} on open
→ {"k":"auth","token":"…"} / {"k":"auth"} first, and after sign-in and sign-out
← {"k":"authed","account":7} / {"k":"authed","rejected":true} / {"k":"authed"}
→ {"k":"sub","ch":"bookings:7"} / {"k":"unsub","ch":"…"}
← {"k":"subok","ch":"…"} / {"k":"subno","ch":"…", …}
← {"k":"upd","ch":"schedule","updates":{"ClubSession":[…]}}
← {"k":"closed","ch":"…"}

A subno carries a refusal (r), an incident id (x), or neither — not authenticated. The server closes the socket with a DwCloseCode:

CodeWhyThe client
1001server stoppingreconnects
1011server failure; reason dw.failed:<incident>reconnects
4008slow consumer: its outbound queue passed outboundLimitBytesreconnects with growing backoff
1003, 1009, 4000a binary frame, a message over maxLiveMessageBytes, a message that does not parsereports it and backs off
4026incompatible; reason dw.updateRequired or dw.protocolUnsupportedstops for good

The server pings every socket every 20 seconds (pingInterval) and drops a peer that has not answered by the next ping.

Versions​

Two versions decide whether a build may talk to a server, and both are checked before anything else about a call — an incompatible client learns that first, whatever else it got wrong.

dwProtocolVersion is the version of the framework's wire. The client sends it as Dw-Protocol (and ?protocol= on the socket). A missing header is a malformed call; another version is answered 426 with dw.protocolUnsupported, and the socket closes with 4026. It means a framework version skew between app and server, which the operator resolves.

Dw-Contract-Version is the version of the project's contract the app was compiled with — the shared package's version:, written by dart run dartway_cli:dartway generate into the protocol both sides are built with (DwWireProtocol.contractVersion), and sent as ?contract= on the socket. Semantic versioning decides compatibility: a change that removes or renames anything an installed app sends or reads raises the breaking line — the major version, or the minor one below 1.0 — and codec-compatible additions does not. A client of an older line than the server's, or one that sends none, is answered 426 with dw.updateRequired. That is the lever for a project's own incompatible change: a renamed field of a project DTO is not a protocol change, and old builds are turned away by the version raised in the same pull request — nothing to remember in an environment at deploy time (#296). A client of a newer line is not refused; the server is the one behind.

Dw-App-Version is the app's own build, from DwFlutterConfig.appVersion. It labels the session key a sign-in makes, and decides nothing.

On the client an incompatibility is terminal. The first 426 (or close 4026) sets dw.incompatibility; from then on every call is answered locally with that refusal, the socket stays closed, and DwFlutterConfig.updateRequiredScreen replaces the app — see ../3-flutter/update-required.md.

Origins​

Calls are same-origin by deployment, and the server sends no CORS headers — also in development (D-039). In production the web app's host proxies /dw/ and /health to the server (../5-tooling/deploy.md); locally dart run dartway_cli:dartway dev does the same (../5-tooling/cli.md). A cross-origin browser call cannot get through anyway: a JSON body needs a preflight, and the server never answers one. Native apps send no Origin and are unaffected.

The live socket checks Origin itself, since browsers do not preflight an upgrade. It is accepted when its host and port are the ones the upgrade was sent to (Host carries no scheme, so the scheme is not compared), or when it is listed in DwServerSettings.allowedOrigins — full origins such as https://app.example.com, compared by scheme, host and port. The server refuses to start with an entry that is not a full origin: a bare host would silently allow every port and scheme of it. An upgrade without Origin (a native app) is allowed; an Origin that does not parse, such as null from a sandboxed page, is refused.

A wire change is a protocol change (D-052)​

An app build on a phone keeps speaking the wire it was compiled with. If the framework changes how a call, an ApiResponse, an update transport, a live message or framework-generated DTO JSON looks on the wire, that build gets no compile error — it gets a body it cannot decode, and its user a broken screen. The protocol version is what turns that into an honest "update the app".

So any such change bumps dwProtocolVersion, in the same change. The rule is enforced by packages/dartway_core_shared/test/wire_golden_test.dart: it encodes every shape as the framework does, compares it with the encodings recorded in test/goldens/wire_golden.dart together with the protocol version they were taken at, and decodes each recorded encoding again, so a reader that stops accepting an encoding it accepted before is a wire change too. When an encoding differs while the version is unchanged, the test fails with the instruction: bump dwProtocolVersion and refresh the golden with DW_UPDATE_GOLDENS=1 dart test test/wire_golden_test.dart. A refresh refuses to overwrite a recorded encoding while the version is still the one it was recorded at — it follows a bump, it does not replace one.

What an installed app may simply not send is not such a change. Dw-Utc-Offset was added without a bump (D-110): an older build sends no header and its caller's offset is unknown, a newer build's header is ignored by an older server — neither side meets a body it cannot read. The header is recorded as a shape of its own (http.utcOffset), not by changing an existing one.

The generated project contract gate​

The generator emits sorted lib/generated/dw_contract.json (descriptor format and codec format 1) from the same resolved models as the codecs. generate --check and check compare the current source-derived shape with a fixed committed Git baseline. Pass --contract-base <revision>; it is resolved once to a commit SHA and reported. Normal project check uses the configured base branch merge-base (including main/master); CI supplies a trusted base SHA explicitly. Working JSON edits, regeneration and feature commits do not replace that baseline.

Field/wire-name removals and renames, recursive type/nullability/patch/default changes, resolved request/result-kind changes, strict-enum additions/removals and enum openness changes are breaking. New data-object update groups are conservatively breaking because an installed client cannot decode an unknown group. New calls and nullable/defaulted/patch fields are codec-compatible: the new decoder accepts missing keys and the old decoder ignores extra keys. Dart required on a nullable field does not make it wire-required. An already-open enum may gain values while retaining all old names and unknown. A changed default is breaking even when both codecs omit the same JSON key.

For example, changing CustomerCard({required this.id}) to CustomerCard({required this.id, required this.title}) rejects the old { "id": 1 } payload. Regenerating at shared version 0.7.2 or 0.7.3 remains red; change it to 0.8.0 and regenerate. The existing runtime then refuses an older client line with 426/dw.updateRequired. A nullable String? title addition accepts the old payload and can stay on 0.7.

First descriptor adoption​

When the trusted base has no descriptor, first adoption requires unchanged hand-written shared package files and the union of shared's own in-repo path-dependency closure at the trusted base and at head. Starting from shared's dependencies, the generator follows each in-repo path dependency's pubspec transitively; dev dependencies and packages used only by the server or app are excluded. The resolved lock identifies dependency sources, including overrides; workspace members supply their own pubspec edges. Base pubspecs and locks are read from Git objects at the trusted SHA. The nearest-lock walk stops at the Git root. A dependency that leaves this closure blocks adoption and is named even if its old files remain unchanged in the repository, so repointing it to Git or an external path cannot hide a contract change. Framework git-subpath dependencies and dependencies outside the Git root at both revisions stay outside the comparison.

For each compared package, the generator compares the union of base-tracked and current files, excluding that package's pubspec.yaml and pubspec.lock. In shared, only the exact paths emitted by the current generator and .dw.dart parts bearing its generated header are excluded; existing parts are judged from base bytes, so adding a header cannot hide a source edit. Hand-written files under lib/generated/ are still compared. Regular files are hashed in one git hash-object --stdin-paths call with worktree-relative paths, applying Git clean/eol filters so a CRLF checkout of LF source passes; symlinks are hashed individually by their verbatim targets. A framework pin move may regenerate codecs and establish the descriptor; the result names the base SHA and adoption proof. If source differs, the gate names the files and asks to split the change: land the pin move and descriptor first, then edit the contract in a following PR. A committed descriptor always wins. No historical dependency fetch or codec reproduction is used. Unsupported formats, custom codecs, custom enum name encoders and default-equality overrides remain blocking contract not verified diagnostics. Coverage is generated project codecs/registry, excluding handler/domain semantics and manually composed external modules. The descriptor is tooling metadata; it adds no runtime compatibility negotiation and does not change dwProtocolVersion.