The application server: what does start() do, and how is it configured?
A DartWay server is one DwAppServer object in the project's server package and one process
running it. It is built from declarations — the protocol, the handlers, the rules — and refuses to
start when they disagree, so an inconsistency is found at deploy time instead of by the first user
who presses the button.
The constructor
abstract final class ExampleServer {
static DwAppServer build({
required DwDatabaseConfig database,
DwFileStorageConfig? storage,
int port = 8080,
DwAuthConfig? auth,
DwServerSettings settings = const DwServerSettings(),
DwPushModule? push,
}) => DwAppServer(
protocol: exampleProtocol,
schema: dartwayExampleSchema,
migrations: appMigrations,
migrationsDirectory: 'lib/src/migrations',
database: database,
auth: auth ?? ExampleAuth.config,
features: [
profileFeature,
clubFeature,
contentFeature,
chatFeature,
adminFeature,
],
files: storage == null ? null : ExampleFiles.storage(storage),
modules: [push ?? ExamplePush.module()],
port: port,
settings: settings,
);
}
That is example/dartway_example_server/lib/dartway_example_server.dart. The skeleton has the same
shape in DartwayStarterServer.build; bin/server.dart builds it from the environment, and tests
build it on a free port against their own database.
| Parameter | Required | What it is |
|---|---|---|
protocol | yes | the DwWireProtocol both sides speak |
schema | no | the DwDatabaseSchema the row classes declare; checked after migrating |
migrations | yes | the project's migrations (namespace app) — migrations |
database | yes | a DwDatabaseConfig — database |
auth | yes | a DwAuthConfig — auth and identity |
features | yes | the project's areas, each a DwServerFeature(name, handlers:, channels:, jobs:, routes:) declared in its own folder: one DwCallHandler per request and command (handlers), a DwChannelRule per channel kind it owns (channels), its jobs (jobs) and its doors for callers that are not the app (routes). server.handlers and .jobs read them together with every module's (modules:, a DwServerModule answers calls and runs jobs too); server.channels and .routes read features' only — a module has neither. A feature's name is its folder under lib/src/: lower-case, and once |
files | no | a DwFileStorage; without it file calls fail — uploads |
modules | no | framework satellites — push, analytics — each a DwServerModule contributing its own migrations, calls and jobs; their calls and jobs are part of server.handlers and .jobs, alongside features' (a module has neither channels nor routes) — push delivery, analytics |
port | no | 8080; 0 binds a free port (server.boundPort tells which) |
address | no | InternetAddress.anyIPv4 |
alerts | no | a DwAlertSink; the log by default — alerts |
logger | no | a DwServerLogger; DwConsoleLogger by default |
settings | no | DwServerSettings, below |
What start() does, in order
-
Validates the declaration and throws
DwStartupExceptionlisting every problem at once:- a handler for a class the protocol does not register, two handlers for one class, or a
handler for a framework call (
DwRequestCode,DwStartUpload, …); - a request or command class the protocol registers and no handler answers;
- an access check written for another call class than its handler's;
- a channel kind without a name, with
:in it, or with two rules; - a job named
dw.…(the framework's), declared twice, recurring with a non-positive interval, or with fewer than one attempt; - an
allowedOriginsentry that is not a full origin; - a file storage problem (a public rule without a public bucket, a bad bucket name, …);
- a route under
/dw, on/health, not starting with/, or declared twice.
- a handler for a class the protocol does not register, two handlers for one class, or a
handler for a framework call (
-
Probes the file buckets, when
filesis set and itsverifyBucketsis on — before anything opens, because a private file behind a public bucket is already leaked (uploads). -
Opens the database pool and proves it with one real connection, so a wrong password fails here and not on the first request.
-
Applies migrations: the framework's own (namespace
dw,DwAppServer.frameworkMigrations) and the project's (app), through the same runner and the same refusals asbin/migrate.dart apply(migrations). -
Checks the schema, when
schemais given: every table and column it declares must exist. Only absences fail — a migration missing from the list would otherwise surface as handlers failing on their first query. Extra tables, columns, types and indexes are left tomigrate checkin CI: a server must not refuse to start over an index an operator added by hand. -
Starts the job executor: syncs the recurring schedule, listens for job notifications and starts
jobWorkersworkers (jobs). -
Binds one port on
dart:io(D-028) and answers:POST /dw/<WireName> requests and commandsGET /dw/live the live update socketGET /health liveness and database reachability* the project's routes, by exact path -
Watches SIGINT and SIGTERM and stops gracefully on either.
Any failure closes what was already opened and rethrows. bin/server.dart does not catch it, so
the process exits non-zero and the deploy sees a failed start rather than a server that half runs.
start() returns once the port is bound; the process stays alive because the port is open.
/health
GET or HEAD /health runs SELECT 1: 200 ok, or 503 database unavailable (logged as a
warning). While the server is stopping it answers 503 stopping. Any other method is 405. It
takes no token and reveals nothing else, so a load balancer or the deploy's probe can call it.
Stopping
stop() — or SIGINT/SIGTERM — stops in this order, each wait bounded by
DwServerSettings.stopTimeout:
- stops accepting connections; calls in flight finish and are answered, their updates delivered;
- closes every live socket with
1001(DwCloseCode.serverStopping), so clients reconnect to the next process instead of reporting an error; - waits for running jobs;
- closes the port, the database pool and the storage client.
A deploy that kills the process without a signal skips all of it: calls are cut mid-answer and a non-transactional job runs again after its lease.
DwServerSettings
Every limit exists because something unbounded would otherwise grow. The defaults suit a mobile app on one server process.
| Field | Default | Why it exists |
|---|---|---|
maxBodyBytes | 1 MiB | The largest call body; a call over it is malformed (400). A handler can override it for its own call; a route passes its own to DwHttpRequest.bytes (413 over it). |
bodyReadTimeout | 30 s | A body dripped one byte at a time holds a connection and a buffer. A late call body is malformed (400); a route's is 408. |
pingInterval | 20 s | Live sockets are pinged; a peer silent until the next ping is dropped, instead of lingering until TCP gives up on a dead mobile link. |
outboundLimitBytes | 8 MiB | Characters queued to one socket; past it the socket closes as a slow consumer rather than growing the server's memory. |
maxLiveMessageBytes | 64 KiB | The largest inbound live message (a token or a channel name). |
closeGrace | 5 s | How long a socket close handshake may take before the socket is destroyed. |
stopTimeout | 15 s | How long stop() waits for calls and jobs. |
allowedOrigins | {} | Browser origins, besides the one the socket is served on, allowed to open the live socket. Below. |
tokenCacheSize | 10 000 | Resolved session tokens kept in memory, so a call authenticates without a query. 0 disables the cache. |
tokenCacheTtl | 1 min | How long a resolved token is trusted. Revocations in this process apply at once; this bounds how late one made elsewhere is noticed. |
jobWorkers | 2 | Concurrent job executions in this process; 0 runs none. |
jobPollInterval | 30 s | The slowest a due job waits when its notification was lost. |
commandOutcomeRetention | 7 days | How long command outcomes are kept for idempotency (D-013); see commands. |
failedJobRetention | 30 days | How long a job out of attempts stays in dw_job for the operator, from when it failed; dw.cleanup removes it after. |
alertsPerSignature | 5 | Alerts of one failure signature per alertWindow (alerts). |
alertWindow | 1 h | The window of that ceiling. |
Configuration comes from the environment
There is no configuration file. The framework reads exactly two groups of variables itself, and only when the project asks it to:
DwDatabaseConfig.fromEnvironment(env)readsDW_DATABASE_HOST,_PORT(5432),_NAME,_USER,_PASSWORD,_SSL(trueunlessfalse),_CA_FILE(unset) and_MAX_CONNECTIONS(10). Every missing or malformed key is reported in oneArgumentError, so a misconfigured deploy fails with the whole list instead of one line per restart._CA_FILE, when set, verifies the server's certificate against that CA (verify-fullinstead ofrequire) and is refused together with_SSL=false— a CA has nothing to verify without TLS. A local Postgres without TLS needsDW_DATABASE_SSL=false. A server without SSL is refused at once when SSL is required — the pool asks it once before the first connection — with an error naming the setting.DwFileStorageConfig.fromEnvironment(env)readsDW_STORAGE_*(uploads).
Everything else is the project's bin/server.dart reading Platform.environment and passing
values in. The skeleton's (template/dartway_starter_server/bin/server.dart) reads:
| Variable | Becomes |
|---|---|
DW_DATABASE_* | DwDatabaseConfig.fromEnvironment |
PORT | port, 8080 by default |
DW_STORAGE_* | the storage configuration; without DW_STORAGE_ENDPOINT the server runs without uploads |
DW_STORAGE_PROVISION=true | DwFileStorageSetup.provision before starting — for a storage the project owns |
DW_ALLOWED_ORIGINS | DwServerSettings.allowedOrigins, comma-separated |
DW_ADMIN_IDENTIFIER | the first administrator, below |
The example's bin/server.dart is the same without the administrator. A value the project wants
configurable is one more line there; the framework does not grow a settings loader for it.
One process, one isolate
A server runs in a single isolate (D-014). The live hub (who is subscribed to what), the session token cache and the alert ceiling live in that isolate's memory.
What this means in practice: run one server process per database. Jobs would coordinate across
processes — workers claim rows with SKIP LOCKED — and a revocation made by one process reaches
another within tokenCacheTtl, but live updates do not cross processes: a command handled by one
process publishes only to sockets connected to that process. Scaling out is processes plus
LISTEN/NOTIFY for updates, which is not built yet.
Same origin, and allowedOrigins
The app reaches the server on the same origin it is served from: the deploy proxies /dw/ and
/health to the server, and dart run dartway_cli:dartway dev does the same locally
(deploy, CLI). So the server answers no CORS
preflight, ever. A browser cannot send a cross-origin application/json call without one, which
closes that door for calls without a list to maintain.
The live socket is a WebSocket upgrade, which browsers do not preflight. The server therefore
checks Origin itself: an upgrade whose origin is the host it was sent to is allowed; an upgrade
without Origin (a native app) is allowed; any other origin must be listed in allowedOrigins,
as a full origin — scheme, host and port (https://app.example.com, http://localhost:5000). A
bare host is refused at startup: it would silently allow every port and scheme of it.
Work after start: server.accounts and server.db
A running server exposes server.db (the pool's DwDatabaseHandle), server.accounts (a
DwAccountService whose revocations end sessions on this server at once), server.boundPort and
server.logger. All but the logger throw StateError before start().
The skeleton used to use them for its first administrator; that is now a startup step, below.
A script with no server running builds DwAccountService(db, auth) over a bare database instead —
see auth and identity. A script that has a server (the seed
starts one on port 0) uses ctx.accounts and needs no such thing.
Startup steps
DwAppServer(startup: [...]) is work done at every start, after the migrations and before the
port opens. The lifecycle is the concept: a step is idempotent by construction — it states what
must be true and makes it so — and nothing has been served when it runs, so a step that throws
stops the start with the previous version still serving.
It runs in a background context, in one transaction, so ctx.db, ctx.accounts, ctx.publish
and ctx.jobs are the ones a handler has. DwStartupStep.problems(auth) is judged with the
server's own, before the database is even opened: a value read from the environment is checked
there.
What goes where, and this is the whole of it:
| Lifecycle | Where |
|---|---|
| once per database, recorded, in every environment | a migration |
| at every start, in every environment, idempotent | a startup step |
| whenever a developer feels like it, never in production | a script (bin/seed_dev.dart) |
The rule for data that is neither obviously one nor the other is who owns the row afterwards.
Rows the operators own from the moment they exist — the first settings, a starting price list —
are seeded once by a migration and never touched by code again. Rows that must agree with the code
— notification templates, the reasons a project refuses something — are a startup step: change the
declaration and the next start of every environment converges on it, with no applied migration to
edit and no down that would delete rows somebody has since corrected.
DwFirstAdministrator
The case every project has. The admin role is granted by an admin, which leaves the first one
nowhere to come from; DW_ADMIN_IDENTIFIER names it per environment, and there is no default
because whoever receives the codes sent to that identifier is the administrator.
DwAppServer(
startup: [DwFirstAdministrator(grant: AppBootstrap.grantAdmin)],
...
);
// AppBootstrap — the project's half: the framework goes as far as the account.
static Future<void> grantAdmin(DwCallContext ctx, int accountId) async {
final profile = (await ctx.db.userProfiles.findFirst(
where: (t) => t.accountId.equals(accountId),
))!;
if (profile.role == UserRole.admin) return;
await ctx.db.userProfiles.update(
profile.copyWith(
role: UserRole.admin,
firstName: profile.firstName.isEmpty ? 'Admin' : null,
),
);
}
The framework ensures the account the way a sign-in does (onAccountCreated runs with a tool
origin, so the project's profile appears with it) and hands it to grant. Unset, the variable is a
warning at every start and nothing else. Set to something the project's normalize refuses, it is
a server that does not start. It runs at every start, so an identifier demoted in the panel is
an administrator again next time — which is the only way back into a project that locked itself
out; the way to stop it is to take the identifier out of the environment.
server.runInContext
Code outside any call sometimes needs what a handler has — ctx.publish, ctx.jobs, ctx.files,
ctx.accounts: a script that publishes, a test that calls a domain service directly rather than
through a command.
final plan = await server.runInContext((ctx) => PlanService.rebuild(ctx, accountId));
It runs the way a job does: a background context with no caller, in one transaction; publications
and revocations are delivered once it commits, and nothing of it is if work throws. The value
work returns is the answer. A test server exposes the same as DwTestServer.runInContext.
server.callAs
A door of the server's own that acts for a signed-in person — an MCP endpoint, an importer holding a person's key — calls the contract, not the database, so that the rules a call has are the rules it keeps:
final result = await server.callAs(
const ListMyInvoices(),
token: bearerToken,
);
final created = await server.callAs(
PayInvoice(invoiceId: id),
token: bearerToken,
idempotencyKey: toolCallId, // a retried tool call answers its first outcome
);
Everything an HTTP call goes through after decoding runs: the session of token, sign-in, validation,
the access rule, the handler, a command's idempotency and transaction, and the updates it publishes,
which reach live connections as after any call. The answer is the typed DwCallResult<R> a client
gets. page positions a page or window request. There is no request over the network, so no
protocol or contract header to keep in step: a door that posted to its own port over loopback carried
them by hand, and broke the day the protocol version moved.
Related
- Handlers and the call context — what runs for each call.
- Wire and versions — the headers and statuses of
/dw/. - Testing —
DwTestServerstarts this server on a free port.