Skip to main content

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.

ParameterRequiredWhat it is
protocolyesthe DwWireProtocol both sides speak
schemanothe DwDatabaseSchema the row classes declare; checked after migrating
migrationsyesthe project's migrations (namespace app) — migrations
databaseyesa DwDatabaseConfig — database
authyesa DwAuthConfig — auth and identity
featuresyesthe 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
filesnoa DwFileStorage; without it file calls fail — uploads
modulesnoframework 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
portno8080; 0 binds a free port (server.boundPort tells which)
addressnoInternetAddress.anyIPv4
alertsnoa DwAlertSink; the log by default — alerts
loggernoa DwServerLogger; DwConsoleLogger by default
settingsnoDwServerSettings, below

What start() does, in order​

  1. Validates the declaration and throws DwStartupException listing 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 allowedOrigins entry 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.
  2. Probes the file buckets, when files is set and its verifyBuckets is on — before anything opens, because a private file behind a public bucket is already leaked (uploads).

  3. Opens the database pool and proves it with one real connection, so a wrong password fails here and not on the first request.

  4. Applies migrations: the framework's own (namespace dw, DwAppServer.frameworkMigrations) and the project's (app), through the same runner and the same refusals as bin/migrate.dart apply (migrations).

  5. Checks the schema, when schema is 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 to migrate check in CI: a server must not refuse to start over an index an operator added by hand.

  6. Starts the job executor: syncs the recurring schedule, listens for job notifications and starts jobWorkers workers (jobs).

  7. Binds one port on dart:io (D-028) and answers:

    POST /dw/<WireName> requests and commands
    GET /dw/live the live update socket
    GET /health liveness and database reachability
    * the project's routes, by exact path
  8. 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:

  1. stops accepting connections; calls in flight finish and are answered, their updates delivered;
  2. closes every live socket with 1001 (DwCloseCode.serverStopping), so clients reconnect to the next process instead of reporting an error;
  3. waits for running jobs;
  4. 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.

FieldDefaultWhy it exists
maxBodyBytes1 MiBThe 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).
bodyReadTimeout30 sA 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.
pingInterval20 sLive sockets are pinged; a peer silent until the next ping is dropped, instead of lingering until TCP gives up on a dead mobile link.
outboundLimitBytes8 MiBCharacters queued to one socket; past it the socket closes as a slow consumer rather than growing the server's memory.
maxLiveMessageBytes64 KiBThe largest inbound live message (a token or a channel name).
closeGrace5 sHow long a socket close handshake may take before the socket is destroyed.
stopTimeout15 sHow 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.
tokenCacheSize10 000Resolved session tokens kept in memory, so a call authenticates without a query. 0 disables the cache.
tokenCacheTtl1 minHow long a resolved token is trusted. Revocations in this process apply at once; this bounds how late one made elsewhere is noticed.
jobWorkers2Concurrent job executions in this process; 0 runs none.
jobPollInterval30 sThe slowest a due job waits when its notification was lost.
commandOutcomeRetention7 daysHow long command outcomes are kept for idempotency (D-013); see commands.
failedJobRetention30 daysHow long a job out of attempts stays in dw_job for the operator, from when it failed; dw.cleanup removes it after.
alertsPerSignature5Alerts of one failure signature per alertWindow (alerts).
alertWindow1 hThe 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) reads DW_DATABASE_HOST, _PORT (5432), _NAME, _USER, _PASSWORD, _SSL (true unless false), _CA_FILE (unset) and _MAX_CONNECTIONS (10). Every missing or malformed key is reported in one ArgumentError, 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-full instead of require) and is refused together with _SSL=false — a CA has nothing to verify without TLS. A local Postgres without TLS needs DW_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) reads DW_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:

VariableBecomes
DW_DATABASE_*DwDatabaseConfig.fromEnvironment
PORTport, 8080 by default
DW_STORAGE_*the storage configuration; without DW_STORAGE_ENDPOINT the server runs without uploads
DW_STORAGE_PROVISION=trueDwFileStorageSetup.provision before starting — for a storage the project owns
DW_ALLOWED_ORIGINSDwServerSettings.allowedOrigins, comma-separated
DW_ADMIN_IDENTIFIERthe 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:

LifecycleWhere
once per database, recorded, in every environmenta migration
at every start, in every environment, idempotenta startup step
whenever a developer feels like it, never in productiona 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.