Skip to content

Adds a local stac dev workflow so Stac screens and themes can be tested without deploying to Stac Cloud.#479

Open
divyanshub024 wants to merge 2 commits into
devfrom
dv/stac-dev
Open

Adds a local stac dev workflow so Stac screens and themes can be tested without deploying to Stac Cloud.#479
divyanshub024 wants to merge 2 commits into
devfrom
dv/stac-dev

Conversation

@divyanshub024
Copy link
Copy Markdown
Member

@divyanshub024 divyanshub024 commented Jun 1, 2026

What changed

  • Added a new stac dev CLI command.
  • Serves generated local artifacts from stac/.build.
  • Added cloud-compatible local endpoints:
    • /screens?screenName=<screen_name>
    • /themes?themeName=<theme_name>
    • /health
  • Watches the local stac/ directory and rebuilds on save.
  • Added device-ready URL output for:
    • local / iOS Simulator / web
    • Android Emulator
    • physical devices over LAN
  • Added StacOptions.apiBaseUrl and copyWith() so debug builds can point to the local server.
  • Updated runtime cloud fetching to respect the configured apiBaseUrl.
  • Fixed local build scanning for worktrees under hidden folders like .codex.
  • Updated CLI and quickstart docs.

Why

Developers should be able to iterate on Stac screens locally without deploying every change to the cloud. This makes the Stac development loop faster and gives a cleaner debug workflow for simulator, emulator, web, and physical-device testing.

Testing

  • Ran dart analyze in packages/stac_cli.
  • Verified stac dev --help.
  • Verified local server startup with examples/movie_app.
  • Verified generated screens/themes are served from local .build output.
  • Verified device URL output for default localhost and --host 0.0.0.0.

Summary by CodeRabbit

  • New Features

    • Added stac dev command to run a local development server for screens and themes
    • Added apiBaseUrl configuration option to override API endpoints for local development
  • Documentation

    • Updated CLI documentation with new dev command, available options (--project, --host, --port, --skip-build, --watch), and usage examples
    • Added local development testing guide to quickstart
    • Added community support links to documentation

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 1, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

This PR adds local development server support via the new stac dev command, enabling developers to test screens and themes locally during development. It introduces configurable API base URL in StacOptions, refactors BuildService helpers for reuse, implements a full HTTP server with watch-mode rebuilds and graceful shutdown, updates the example app, and provides comprehensive documentation.

Changes

Local Development Server Feature

Layer / File(s) Summary
API Base URL Configuration
packages/stac_core/lib/core/stac_options.dart
StacOptions adds configurable apiBaseUrl field with default https://api.stac.dev, documentation for local debug usage, and new copyWith() method supporting selective field replacement.
StacCloud Integration
packages/stac/lib/src/services/stac_cloud.dart
StacCloud derives API base URL dynamically from StacService.options?.apiBaseUrl with fallback to default, trimming whitespace and removing trailing slashes for proper URL construction.
BuildService Extraction
packages/stac_cli/lib/src/services/build_service.dart
Extracts resolveProjectDir() and loadBuildConfigFromOptions() as public helpers; refines Dart file filtering to check path segments for hidden/build directories instead of substring matching.
DevService Local Server
packages/stac_cli/lib/src/services/dev_service.dart
New DevService runs HTTP server on configurable host/port, serves stac/.build artifacts via /screens and /themes endpoints, supports optional watch-mode rebuilds with debouncing, CORS, graceful shutdown with signal handlers, and device URL logging.
DevCommand CLI Integration
packages/stac_cli/bin/stac_cli.dart, packages/stac_cli/lib/src/commands/dev_command.dart
New DevCommand parses project/host/port/skip-build/watch arguments, validates port range, delegates to DevService.serve(), and registers with CommandRunner alongside existing commands.
Command Service Lazy Init
packages/stac_cli/lib/src/commands/{deploy,init,project/*}_command.dart
Converts DeployCommand, InitCommand, CreateCommand, and ListCommand service dependencies from eager final to deferred late final initialization.
Example App Configuration
examples/movie_app/lib/main.dart, examples/movie_app/ios/..., examples/movie_app/stac/app_theme.dart
Updates Stac.initialize to override apiBaseUrl to local debug server; adds iOS scene manifest and CORS configuration; modernizes plugin registration via FlutterImplicitEngineDelegate; updates theme primary color; removes minimum OS version declaration.
Documentation Updates
docs/cli.mdx, docs/quickstart.mdx, packages/stac_cli/README.md
Documents stac dev command in CLI reference with flags and example; adds "Test Locally" section in quickstart showing local debug setup; updates README quick-start flow to include stac dev between build and deploy.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • StacDev/stac#381: Both PRs modify examples/movie_app/lib/main.dart's Stac.initialize to support apiBaseUrl override for local development.
  • StacDev/stac#386: Both PRs refactor packages/stac/lib/src/services/stac_cloud.dart to handle configurable API base URL in the cloud-fetch pathway.
  • StacDev/stac#456: Both PRs extend the CLI command registry in packages/stac_cli/bin/stac_cli.dart to add new development workflow commands.

Suggested reviewers

  • Potatomonsta
  • rahulbisht25

Poem

🐰 A dev server springs to life with pride,
Serving screens and themes side by side,
With watch-mode builds and URLs bright,
Local testing shines—developer delight! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main feature added: a local stac dev workflow for testing screens and themes without cloud deployment.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dv/stac-dev

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
examples/movie_app/lib/main.dart (1)

22-22: ⚡ Quick win

Scope the localhost override to debug builds.

This override is applied unconditionally, so release/profile builds will also point at http://127.0.0.1:45700 and fail to reach Stac Cloud. The intent (per the PR) is local debugging only, so gate it behind kDebugMode.

♻️ Proposed change
+import 'package:flutter/foundation.dart';
   await Stac.initialize(
-    options: defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700'),
+    options: kDebugMode
+        ? defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700')
+        : defaultStacOptions,
     dio: dio,
     parsers: [MovieCarouselParser()],
   );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/movie_app/lib/main.dart` at line 22, The localhost apiBaseUrl
override is applied unconditionally on defaultStacOptions; restrict it to debug
builds by using kDebugMode so release/profile use the default endpoint. Modify
the code that sets options (the call to defaultStacOptions.copyWith(apiBaseUrl:
'http://127.0.0.1:45700')) to only apply copyWith when kDebugMode is true
(importing flutter/foundation.dart if needed), otherwise leave options as
defaultStacOptions (or copyWith without the override).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@examples/movie_app/stac/app_theme.dart`:
- Around line 8-9: The current color pair primary: '`#212121`' and onPrimary:
'`#050608`' fails WCAG contrast; update the onPrimary value (or adjust primary) so
the contrast ratio is >= 4.5:1 for normal text (or >= 3:1 for large text).
Locate the primary and onPrimary declarations in app_theme.dart and replace
onPrimary with a much lighter color (e.g., '`#FFFFFF`' or a light gray like
'`#F5F5F5`') or compute a contrast-safe foreground dynamically; ensure the chosen
onPrimary yields at least 4.5:1 contrast against '`#212121`' before committing.

In `@packages/stac_cli/lib/src/services/dev_service.dart`:
- Around line 122-181: The handler dispatched via unawaited(server.listen(...))
can throw after CORS headers are set and leave the HttpResponse open; update
_handleRequest to wrap its entire body in a try/catch/finally (or use try { ...
} on success paths } catch (e, st) { await _writeJson(response,
HttpStatus.internalServerError, {'error': 'Internal server error'});
ConsoleLogger.error('...', e, st); } finally { if (!response.headersSent &&
response.contentLength == 0) { /* ensure close even if nothing written */ }
await response.close(); }) so that any exception (including those from
_serveArtifact, stat(), or file.readAsString()) is caught and the response is
always closed; use _writeJson for a 500 where feasible and reference
_handleRequest, _serveArtifact, _writeJson, and _setCorsHeaders when making the
change.

---

Nitpick comments:
In `@examples/movie_app/lib/main.dart`:
- Line 22: The localhost apiBaseUrl override is applied unconditionally on
defaultStacOptions; restrict it to debug builds by using kDebugMode so
release/profile use the default endpoint. Modify the code that sets options (the
call to defaultStacOptions.copyWith(apiBaseUrl: 'http://127.0.0.1:45700')) to
only apply copyWith when kDebugMode is true (importing flutter/foundation.dart
if needed), otherwise leave options as defaultStacOptions (or copyWith without
the override).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61130290-b666-4140-862f-65646a299e8d

📥 Commits

Reviewing files that changed from the base of the PR and between 29491d8 and 77a05ac.

📒 Files selected for processing (18)
  • docs/cli.mdx
  • docs/quickstart.mdx
  • examples/movie_app/ios/Flutter/AppFrameworkInfo.plist
  • examples/movie_app/ios/Runner/AppDelegate.swift
  • examples/movie_app/ios/Runner/Info.plist
  • examples/movie_app/lib/main.dart
  • examples/movie_app/stac/app_theme.dart
  • packages/stac/lib/src/services/stac_cloud.dart
  • packages/stac_cli/README.md
  • packages/stac_cli/bin/stac_cli.dart
  • packages/stac_cli/lib/src/commands/deploy_command.dart
  • packages/stac_cli/lib/src/commands/dev_command.dart
  • packages/stac_cli/lib/src/commands/init_command.dart
  • packages/stac_cli/lib/src/commands/project/create_command.dart
  • packages/stac_cli/lib/src/commands/project/list_command.dart
  • packages/stac_cli/lib/src/services/build_service.dart
  • packages/stac_cli/lib/src/services/dev_service.dart
  • packages/stac_core/lib/core/stac_options.dart
💤 Files with no reviewable changes (1)
  • examples/movie_app/ios/Flutter/AppFrameworkInfo.plist

Comment on lines +8 to 9
primary: '#212121',
onPrimary: '#050608',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python3 - <<'PY'
def lum(hex_):
    h=hex_.lstrip('#'); r,g,b=(int(h[i:i+2],16)/255 for i in (0,2,4))
    f=lambda c:(c/12.92) if c<=0.03928 else ((c+0.055)/1.055)**2.4
    R,G,B=f(r),f(g),f(b)
    return 0.2126*R+0.7152*G+0.0722*B
def ratio(a,b):
    la,lb=lum(a),lum(b); hi,lo=max(la,lb),min(la,lb)
    return (hi+0.05)/(lo+0.05)
print("primary/onPrimary:", round(ratio('`#212121`','`#050608`'),2))
PY

Repository: StacDev/stac

Length of output: 80


Fix onPrimary contrast against primary
In examples/movie_app/stac/app_theme.dart (primary #212121, onPrimary #050608), the WCAG contrast ratio is ~1.26:1, far below AA (4.5:1 normal / 3:1 large). Update onPrimary to a significantly lighter content color (or adjust primary) so text/icons on primary surfaces remain readable.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@examples/movie_app/stac/app_theme.dart` around lines 8 - 9, The current color
pair primary: '`#212121`' and onPrimary: '`#050608`' fails WCAG contrast; update the
onPrimary value (or adjust primary) so the contrast ratio is >= 4.5:1 for normal
text (or >= 3:1 for large text). Locate the primary and onPrimary declarations
in app_theme.dart and replace onPrimary with a much lighter color (e.g.,
'`#FFFFFF`' or a light gray like '`#F5F5F5`') or compute a contrast-safe foreground
dynamically; ensure the chosen onPrimary yields at least 4.5:1 contrast against
'`#212121`' before committing.

Comment on lines +122 to +181
server.listen(
(request) =>
unawaited(_handleRequest(request: request, outputDir: outputDir)),
onError: (Object error, StackTrace stackTrace) {
ConsoleLogger.error('Server error: $error');
},
);

await done.future;
}

Future<void> _handleRequest({
required HttpRequest request,
required String outputDir,
}) async {
final response = request.response;
_setCorsHeaders(response);

if (request.method == 'OPTIONS') {
response.statusCode = HttpStatus.noContent;
await response.close();
return;
}

if (request.method != 'GET') {
await _writeJson(response, HttpStatus.methodNotAllowed, {
'error': 'Only GET is supported by stac dev.',
});
return;
}

switch (request.uri.path) {
case '/':
case '/health':
await _writeJson(response, HttpStatus.ok, {'status': 'ok'});
return;
case '/screens':
await _serveArtifact(
response: response,
outputDir: outputDir,
artifactDirName: 'screens',
artifactName: request.uri.queryParameters['screenName'],
missingNameMessage: 'Missing screenName query parameter.',
);
return;
case '/themes':
await _serveArtifact(
response: response,
outputDir: outputDir,
artifactDirName: 'themes',
artifactName: request.uri.queryParameters['themeName'],
missingNameMessage: 'Missing themeName query parameter.',
);
return;
default:
await _writeJson(response, HttpStatus.notFound, {
'error': 'Unknown stac dev endpoint: ${request.uri.path}',
});
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Unhandled errors in _handleRequest leak open connections.

_handleRequest is dispatched via unawaited(...), and server.listen's onError only catches stream errors — not rejections from the handler future. If anything inside throws after CORS headers are set (e.g. artifactFile.readAsString() or stat() on Line 217-218 failing on a permission/IO error), the exception becomes an unhandled async error and the HttpResponse is never closed, so the client hangs until timeout.

Wrap the handler body so the response is always finalized.

🛡️ Proposed fix to guarantee the response is closed
   Future<void> _handleRequest({
     required HttpRequest request,
     required String outputDir,
   }) async {
     final response = request.response;
-    _setCorsHeaders(response);
-
-    if (request.method == 'OPTIONS') {
-      response.statusCode = HttpStatus.noContent;
-      await response.close();
-      return;
-    }
-
-    if (request.method != 'GET') {
-      await _writeJson(response, HttpStatus.methodNotAllowed, {
-        'error': 'Only GET is supported by stac dev.',
-      });
-      return;
-    }
-
-    switch (request.uri.path) {
-      case '/':
-      case '/health':
-        await _writeJson(response, HttpStatus.ok, {'status': 'ok'});
-        return;
-      case '/screens':
-        await _serveArtifact(
-          response: response,
-          outputDir: outputDir,
-          artifactDirName: 'screens',
-          artifactName: request.uri.queryParameters['screenName'],
-          missingNameMessage: 'Missing screenName query parameter.',
-        );
-        return;
-      case '/themes':
-        await _serveArtifact(
-          response: response,
-          outputDir: outputDir,
-          artifactDirName: 'themes',
-          artifactName: request.uri.queryParameters['themeName'],
-          missingNameMessage: 'Missing themeName query parameter.',
-        );
-        return;
-      default:
-        await _writeJson(response, HttpStatus.notFound, {
-          'error': 'Unknown stac dev endpoint: ${request.uri.path}',
-        });
-    }
+    try {
+      _setCorsHeaders(response);
+
+      if (request.method == 'OPTIONS') {
+        response.statusCode = HttpStatus.noContent;
+        await response.close();
+        return;
+      }
+
+      if (request.method != 'GET') {
+        await _writeJson(response, HttpStatus.methodNotAllowed, {
+          'error': 'Only GET is supported by stac dev.',
+        });
+        return;
+      }
+
+      switch (request.uri.path) {
+        case '/':
+        case '/health':
+          await _writeJson(response, HttpStatus.ok, {'status': 'ok'});
+          return;
+        case '/screens':
+          await _serveArtifact(
+            response: response,
+            outputDir: outputDir,
+            artifactDirName: 'screens',
+            artifactName: request.uri.queryParameters['screenName'],
+            missingNameMessage: 'Missing screenName query parameter.',
+          );
+          return;
+        case '/themes':
+          await _serveArtifact(
+            response: response,
+            outputDir: outputDir,
+            artifactDirName: 'themes',
+            artifactName: request.uri.queryParameters['themeName'],
+            missingNameMessage: 'Missing themeName query parameter.',
+          );
+          return;
+        default:
+          await _writeJson(response, HttpStatus.notFound, {
+            'error': 'Unknown stac dev endpoint: ${request.uri.path}',
+          });
+      }
+    } catch (e) {
+      ConsoleLogger.error('Request handling failed: $e');
+      // Headers may already be sent; best-effort close to avoid a hung client.
+      await response.close().catchError((_) {});
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
server.listen(
(request) =>
unawaited(_handleRequest(request: request, outputDir: outputDir)),
onError: (Object error, StackTrace stackTrace) {
ConsoleLogger.error('Server error: $error');
},
);
await done.future;
}
Future<void> _handleRequest({
required HttpRequest request,
required String outputDir,
}) async {
final response = request.response;
_setCorsHeaders(response);
if (request.method == 'OPTIONS') {
response.statusCode = HttpStatus.noContent;
await response.close();
return;
}
if (request.method != 'GET') {
await _writeJson(response, HttpStatus.methodNotAllowed, {
'error': 'Only GET is supported by stac dev.',
});
return;
}
switch (request.uri.path) {
case '/':
case '/health':
await _writeJson(response, HttpStatus.ok, {'status': 'ok'});
return;
case '/screens':
await _serveArtifact(
response: response,
outputDir: outputDir,
artifactDirName: 'screens',
artifactName: request.uri.queryParameters['screenName'],
missingNameMessage: 'Missing screenName query parameter.',
);
return;
case '/themes':
await _serveArtifact(
response: response,
outputDir: outputDir,
artifactDirName: 'themes',
artifactName: request.uri.queryParameters['themeName'],
missingNameMessage: 'Missing themeName query parameter.',
);
return;
default:
await _writeJson(response, HttpStatus.notFound, {
'error': 'Unknown stac dev endpoint: ${request.uri.path}',
});
}
}
Future<void> _handleRequest({
required HttpRequest request,
required String outputDir,
}) async {
final response = request.response;
try {
_setCorsHeaders(response);
if (request.method == 'OPTIONS') {
response.statusCode = HttpStatus.noContent;
await response.close();
return;
}
if (request.method != 'GET') {
await _writeJson(response, HttpStatus.methodNotAllowed, {
'error': 'Only GET is supported by stac dev.',
});
return;
}
switch (request.uri.path) {
case '/':
case '/health':
await _writeJson(response, HttpStatus.ok, {'status': 'ok'});
return;
case '/screens':
await _serveArtifact(
response: response,
outputDir: outputDir,
artifactDirName: 'screens',
artifactName: request.uri.queryParameters['screenName'],
missingNameMessage: 'Missing screenName query parameter.',
);
return;
case '/themes':
await _serveArtifact(
response: response,
outputDir: outputDir,
artifactDirName: 'themes',
artifactName: request.uri.queryParameters['themeName'],
missingNameMessage: 'Missing themeName query parameter.',
);
return;
default:
await _writeJson(response, HttpStatus.notFound, {
'error': 'Unknown stac dev endpoint: ${request.uri.path}',
});
}
} catch (e) {
ConsoleLogger.error('Request handling failed: $e');
// Headers may already be sent; best-effort close to avoid a hung client.
await response.close().catchError((_) {});
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/stac_cli/lib/src/services/dev_service.dart` around lines 122 - 181,
The handler dispatched via unawaited(server.listen(...)) can throw after CORS
headers are set and leave the HttpResponse open; update _handleRequest to wrap
its entire body in a try/catch/finally (or use try { ... } on success paths }
catch (e, st) { await _writeJson(response, HttpStatus.internalServerError,
{'error': 'Internal server error'}); ConsoleLogger.error('...', e, st); }
finally { if (!response.headersSent && response.contentLength == 0) { /* ensure
close even if nothing written */ } await response.close(); }) so that any
exception (including those from _serveArtifact, stat(), or file.readAsString())
is caught and the response is always closed; use _writeJson for a 500 where
feasible and reference _handleRequest, _serveArtifact, _writeJson, and
_setCorsHeaders when making the change.

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