2026-09-29-environment-and-outbound-http
title: "The environment is read once into AppEnvironment, and another service's API is ctx.http" affects: dartway_core_server: "0.21.0-dev.10" dartway_cli: "0.16.0"
Who is affected
Every project: dart run dartway_cli:dartway check now fails on Platform.environment in the server package's lib/
outside lib/src/core/environment.dart (forbiddenEnvironmentRead), and on HttpClient( or an
import of package:http/… anywhere in its lib/ (forbiddenHttpClient) — and, in its bin/, on
Platform.environment outside DwLocalEnvironment.overlay(…) and on any map read by a variable's
name (env['PORT']). So every project whose bin/server.dart parses PORT, DW_ALLOWED_ORIGINS or
DW_STORAGE_PROVISION by hand moves, as does one that calls AppFiles.storageConfig (the
skeleton's, now gone). DwFirstAdministrator no longer reads the environment (variable: and
environment: are gone; it takes identifier:), and DwAppServer.start no longer reads
DW_MIGRATE_ONLY (it takes migrateOnly:). A class that implements DwCallContext itself adds
http.
What to change
1. One environment file. Create lib/src/core/environment.dart with the framework's variables
and one typed sub-config per concern of your own; move every read of a variable there:
final class AppEnvironment {
const AppEnvironment({required this.server, required this.sms});
static AppEnvironment read(Map<String, String> variables) =>
DwEnvironmentReader.read(variables, (read) => AppEnvironment(
server: DwServerEnvironment.read(
read,
defaultPublicBucket: AppFiles.defaultPublicBucket,
defaultPrivateBucket: AppFiles.defaultPrivateBucket,
),
sms: AppSmsEnvironment(
login: read.required('SMS_LOGIN'),
password: read.required('SMS_PASSWORD'),
),
));
final DwServerEnvironment server;
final AppSmsEnvironment sms;
}
read.required / optional / integer(…, fallback:) / flag / list, and read.report('…') for a
rule across variables (both or neither). A value that was read on first use with a ?? throw is
read.required now: the start fails with every missing variable at once. Keep the bucket defaults
as constants on AppFiles (defaultPublicBucket, defaultPrivateBucket) and delete
AppFiles.storageConfig.
2. bin/server.dart parses nothing:
- final env = DwLocalEnvironment.overlay(Platform.environment);
- final storage = AppFiles.storageConfig(env);
- if (storage != null && env['DW_STORAGE_PROVISION'] == 'true') { … }
- port: int.parse(env['PORT'] ?? '8080'),
- allowedOrigins: { for (final entry in (env['DW_ALLOWED_ORIGINS'] ?? '').split(',')) … },
+ final env = AppEnvironment.read(DwLocalEnvironment.overlay(Platform.environment));
+ final storage = env.server.storage;
+ if (storage != null && env.server.provisionStorage) {
+ await DwFileStorageSetup.provision(storage);
+ }
+ database: env.server.database,
+ port: env.server.port,
+ settings: DwServerSettings(allowedOrigins: env.server.allowedOrigins),
and hand each sub-config to the server factory, which passes it to what uses it. The first
administrator and migrate-only come from the same read. Both are required parameters now —
start({required bool migrateOnly}), DwFirstAdministrator({required String? identifier}) — so the
compiler names every place that has to pass them (migrateOnly: false in bin/seed_dev.dart,
identifier: null where no administrator is meant); skipping them would have made a deploy's
migration step start serving and the administrator silently disappear:
+ adminIdentifier: env.server.adminIdentifier, // → DwFirstAdministrator(identifier: …)
- await server.start();
+ await server.start(migrateOnly: env.server.migrateOnly);
In the factory: DwFirstAdministrator(grant: …, identifier: adminIdentifier), with
required String? adminIdentifier as its parameter so the entry points and test harnesses say what
they mean.
A project that named its administrator with a variable of its own (Molodey's
MOLODEY_BOOTSTRAP_ADMIN, U90's AppBootstrap.adminVariable) either renames it to
DW_ADMIN_IDENTIFIER in deploy/config.yaml and the secret store (dartway secret set DW_ADMIN_IDENTIFIER --env <name>), or keeps its name, reads it in AppEnvironment —
read.optional('MOLODEY_BOOTSTRAP_ADMIN') — and passes that as identifier:. bin/seed_dev.dart
reads AppEnvironment the same way; any other env['NAME'] in bin/ becomes a field of
AppEnvironment.
Studio: StudioEnvironment, WorkerConfiguration.fromEnvironment and
AutomationConfiguration.fromEnvironment fold into sub-configs of AppEnvironment, and so do the
reads of STUDIO_CLAUDE_EXECUTABLE, STUDIO_WORKSPACES, STUDIO_DARTWAY_CLI and PATH — a child
process's PATH is a field (read.optional('PATH')) passed to what spawns it. A token only a
command-line entry point needs — bin/import_github.dart's STUDIO_TOKEN — is read there, not in
AppEnvironment:
final token = DwEnvironmentReader.read(
DwLocalEnvironment.overlay(Platform.environment),
(read) => read.required('STUDIO_TOKEN'),
);
3. Outbound HTTP through ctx.http. Replace each client of your own:
- final client = HttpClient()..connectionTimeout = timeout;
- final request = await client.postUrl(url);
- request.headers.contentType = ContentType.json;
- request.write(jsonEncode(body));
- final response = await request.close();
- final text = await utf8.decodeStream(response);
+ final response = await ctx.http.post(url, json: body, timeout: timeout);
+ final text = response.body;
package:http's client.post(url, body: …) becomes ctx.http.post(url, body: …) the same way. A
non-2xx status is still an answer (response.statusCode, isSuccess); what used to be a
SocketException, TimeoutException or ClientException is DwOutboundException
(timedOut). A redirect that must not carry a token is followRedirects: false. A service class
takes ctx.http (a DwOutboundHttp) per call instead of holding a client.
The response body cap is 10 MiB, including JSON and base64 text, rather than the decoded
image size. An image provider can exceed it even when the binary image is smaller. Raise the cap
for that call, for example ctx.http.post(url, json: body, maxResponseBytes: 64 << 20) (64 MiB),
or set DwServerSettings(outboundMaxResponseBytes: 64 << 20) on the server if every integration
needs it. Prefer a per-call cap: the whole response is buffered in memory, and concurrent requests
multiply that cost. A response past the cap throws DwOutboundException.
A command-line tool without a server context can use the same provider class. From
dartway_core_server 0.22.0, create a real client through the public library:
final http = DwOutboundHttp.network(
settings: const DwServerSettings(outboundMaxResponseBytes: 64 << 20),
);
try {
await gateway.send(http, phone, text);
} finally {
http.close();
}
The client reuses connections; the tool owns and closes it. Its default timeout (30 s), cap
(10 MiB), and safe logging match ctx.http; pass log: to use the tool's logger. Keep the
entry point in bin/ or tool/. Server code still takes ctx.http, so DwTestServer continues
to answer it from its fake; forbiddenHttpClient rejects the standalone factory in server lib/.
Do not close a borrowed ctx.http or fake.client(); their owner manages the transport.
4. Tests drop their transport seams. A transport interface threaded through the server factory for tests (Molodey's SMSC and amoCRM transports) is deleted together with its parameter; the test scripts the test server's fake instead:
server.http.when(
(request) => request.url.host == 'smsc.ru',
(request) => DwOutboundResponse(200, json: {'id': 1, 'cnt': 1}),
);
…
expect(server.http.requests.single.form['phones'], '79990000001');
A request no rule answers fails the call with a StateError, so every test that reaches a provider
now says what the provider answers.
5. Unit tests of a client class (no server) hand it the fake's client instead of a transport of their own:
final http = DwFakeOutboundHttp()
..when((request) => true, (request) => DwOutboundResponse(200, body: 'OK'));
await SmscGateway(settings).send(http.client(), phone, text);
expect(http.requests.single.form['phones'], phone);
A DwCallContext of your own (a test double) adds DwOutboundHttp get http — fake.client().
How to check
dart run dartway_cli:dartway check reports no forbiddenEnvironmentRead or forbiddenHttpClient;
dart run bin/server.dart with a variable removed stops with DwEnvironmentException naming it;
dart run dartway_cli:dartway test is green.