Skip to content

feat(flutter): add Flutter web provider - #679

Open
krushiraj wants to merge 3 commits into
railwayapp:mainfrom
krushiraj:feat/flutter-web-provider
Open

feat(flutter): add Flutter web provider#679
krushiraj wants to merge 3 commits into
railwayapp:mainfrom
krushiraj:feat/flutter-web-provider

Conversation

@krushiraj

Copy link
Copy Markdown

Motivation

Flutter web apps match no provider today, so building one fails with "no
provider detected" and the only workaround is a hand-written Dockerfile. There
is an open request for Flutter web support on Nixpacks/Coolify, and the same gap
exists here.

Description

Adds a flutter provider that builds the web target and serves it with Caddy,
reusing the existing static-serving pattern.

Detection requires all three: pubspec.yaml, web/index.html, and a Flutter
SDK dependency (dependencies > flutter > sdk: flutter, or a top-level
flutter: section). The web/index.html requirement is deliberate — a
mobile-only Flutter repo fails deep inside the Flutter toolchain with "This
application is not configured to build on the web", so falling through to the
next provider gives a clearer error.

Version resolution, lowest to highest precedence: latest, the
environment > flutter constraint in pubspec.yaml, an FVM pin (.fvmrc or
.fvm/fvm_config.json), mise/asdf version files, then
RAILPACK_FLUTTER_VERSION. This follows the ordering in node.go. FVM pins
naming a channel (stable, beta) are skipped, since a channel is not
resolvable to a release.

No custom SDK download logic — mise ships registry/flutter.toml with an
http: backend and per-platform checksums, and git/xz-utils are already in
the builder image.

Three decisions worth calling out, all of which came out of running the build
rather than reading it:

  1. PUB_CACHE lives at /app/.pub-cache, not a BuildKit cache mount.
    Dart does not vendor packages the way node_modules does — dart2js
    resolves imports through absolute paths in
    .dart_tool/package_config.json that point into the pub cache. A cache
    mount is not part of the layer, so the packages vanish between the install
    and build steps and the build fails with Error when reading '.../vector_math_64.dart'. This mirrors why the node provider caches
    /app/node_modules/.cache rather than node_modules itself.

  2. --no-web-resources-cdn is passed by default. Without it the built app
    fetches CanvasKit from https://www.gstatic.com at runtime and renders a
    blank page with no error when that host is unreachable — while still
    returning HTTP 200. The SDK bundles canvaskit/ into build/web
    regardless, so serving it locally costs no image size (66.1 MB either way).
    It moves ~2.3 MB gzipped per cold load onto the deployer's bandwidth, which
    is the real trade-off; HTTP cache partitioning means the shared CDN copy is
    rarely reused across origins anyway. This follows the precedent of defaults
    changed for the deployment context (staticfile.go index fallback,
    NEXT_TELEMETRY_DISABLED, DOTNET_CLI_TELEMETRY_OPTOUT) rather than the
    opt-in pattern used for Playwright browsers, which exists because that
    install costs hundreds of MB. RAILPACK_BUILD_CMD restores the CDN
    behaviour.

  3. The Caddyfile uses a route block. Caddy runs header before
    try_files rewrites, so a path /index.html matcher never matches a
    request for /. Inside a route, directives run in written order, so the
    Cache-Control: no-cache on Flutter's bootstrap files (index.html,
    flutter_bootstrap.js, flutter_service_worker.js, main.dart.js,
    version.json) actually applies. Those files have stable names and are
    versioned by the service worker's resource map rather than a content hash,
    so caching them strands users on a stale build. assets/ and canvaskit/
    are deliberately left on normal caching — they are not content-hashed
    either, so immutable would be wrong.

Index fallback defaults to true so usePathUrlStrategy() routes resolve,
overridable through the existing Staticfile index_fallback key.

Test

examples/flutter-web is a minimal Flutter web app with two httpCheck cases:
/ and a client-side route, both expecting 200, the latter proving index
fallback. Both are pinned to linux/amd64 because Flutter publishes Linux SDK
archives for x64 only — releases_linux.json carries no arm64 entry and mise's
registry defines no linux-arm64 platform.

Unit tests cover detection (web app, app with no assets section, mobile-only,
plain Dart package, node app, static site), pubspec parsing, and FVM version
precedence including the legacy config format and channel pins.

Verified manually beyond the automated checks: the built container was loaded in
a real browser, confirming the Dart app initialises (flutter-view,
flt-glass-pane present, flt-renderer="canvaskit") and renders. With
www.gstatic.com blocked at DNS, the app still renders and CanvasKit is served
from /canvaskit/chromium/*; before --no-web-resources-cdn the same condition
produced a blank page while the HTTP check still passed.

Links

Builds the Flutter web target and serves build/web with Caddy. Detection
requires pubspec.yaml, web/index.html, and a Flutter SDK dependency, so
mobile-only repos fall through instead of failing inside the Flutter
toolchain.

Version resolution follows the node.go ordering: latest, the
environment > flutter constraint, an FVM pin, mise version files, then
RAILPACK_FLUTTER_VERSION.

PUB_CACHE lives at /app/.pub-cache rather than a BuildKit cache mount.
dart2js resolves imports through absolute paths in package_config.json
that point into the pub cache, so a cache mount drops the packages
between the install and build steps.

Pass --no-web-resources-cdn so CanvasKit is served from the app itself.
Flutter otherwise loads it from gstatic.com at runtime and renders a
blank page, still returning HTTP 200, when that host is unreachable. The
SDK bundles canvaskit/ regardless, so image size is unchanged.

The Caddyfile uses a route block because Caddy runs header before
try_files rewrites, so a path matcher for /index.html never matches a
request for /.
CanvasKit ships ~7.6MB of *.symbols files across canvaskit/ and
canvaskit/chromium/. They only symbolicate engine stack traces in a
debugger and are never requested at runtime, so they are dead weight in
the runtime image. Dropping them takes build/web from 40MB to 32MB.

Only the symbol maps are excluded, not the renderer payloads. skwasm and
wimp go unfetched under the default CanvasKit renderer, but a --wasm
build through RAILPACK_BUILD_CMD needs skwasm.
GHA arm runners emulate amd64 through QEMU, and the Flutter web build
does not finish inside the 20m integration test timeout. Skip on arm64
the same way node-puppeteer does; native amd64 CI still covers it.

Also regenerates the plan snapshot for the mise 2026.8.0 bump on main.
@krushiraj
krushiraj force-pushed the feat/flutter-web-provider branch from 92dd516 to 8dd006f Compare August 7, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant