offline_web_proxy 0.5.0
offline_web_proxy: ^0.5.0 copied to clipboard
Offline-capable local proxy server for Flutter WebView. Provides seamless online/offline operation when converting existing web systems to mobile apps.
offline_web_proxy #
An offline-compatible local proxy server that operates within Flutter WebView. It aims to enable existing web systems to function seamlessly in mobile apps without requiring awareness of online/offline states.
Features #
Core Functions #
- Intercepts HTTP requests from WebView through a local proxy server
- Forwards requests to upstream server when online, serves responses from cache when offline
- Queues update requests (POST/PUT/DELETE) when offline
- Automatically sends queued requests upon online recovery for seamless offline support
- Local serving of static resources
Offline Support #
- Combines RFC-compliant cache control with offline compatibility
- Intelligent cache management based on Cache-Control and Expires headers
- Ignores no-cache directives and uses stale cache when offline
- Prevents duplicate execution through idempotency guarantees
Queuing System #
- Guarantees request order through FIFO (First In, First Out)
- Automatic retry with exponential backoff
- Persistence for continued processing after restarts
Cookie Management #
- RFC-compliant cookie evaluation and management
- AES-256 encrypted persistence
- Encryption key storage in secure storage, with one-time migration from legacy plain cookie storage when available
- If the secure-storage key is lost, existing encrypted cookies can no longer be decrypted and users must sign in again
- High-speed access through memory caching
Installation #
Add the following to your pubspec.yaml:
dependencies:
offline_web_proxy: ^0.4.0
# Add the following if using WebView
# webview_flutter: ^4.4.2
Usage #
Basic Setup #
import 'package:offline_web_proxy/offline_web_proxy.dart';
// Note: Add the following dependency if using WebView
// import 'package:webview_flutter/webview_flutter.dart';
class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
late OfflineWebProxy proxy;
int? proxyPort;
@override
void initState() {
super.initState();
_startProxy();
}
Future<void> _startProxy() async {
proxy = OfflineWebProxy();
// Configuration object (optional)
final config = ProxyConfig(
origin: 'https://api.example.com', // Upstream server URL
cacheMaxSize: 200 * 1024 * 1024, // Maximum cache size (200MB)
);
// Start proxy server
proxyPort = await proxy.start(config: config);
print('Proxy server started: http://127.0.0.1:$proxyPort');
setState(() {});
}
@override
void dispose() {
proxy.stop();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (proxyPort == null) {
return Scaffold(
body: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(title: Text('Offline-Ready WebView')),
body: WebView(
initialUrl: 'http://127.0.0.1:$proxyPort/app',
javascriptMode: JavascriptMode.unrestricted,
), // Note: webview_flutter dependency required
);
}
}
Advanced Configuration with Configuration File #
Create assets/config/config.yaml for detailed configuration:
proxy:
server:
origin: "https://api.example.com"
cache:
maxSizeBytes: 209715200 # 200MB
ttl:
"text/html": 3600 # HTML: 1 hour
"text/css": 86400 # CSS: 24 hours
"image/*": 604800 # Images: 7 days
"default": 86400 # Others: 24 hours
# Startup cache update
startup:
enabled: true
paths:
- "/config"
- "/user/profile"
- "/assets/app.css"
queue:
drainIntervalSeconds: 3 # Queue processing interval
retryBackoffSeconds: [1, 2, 5, 10, 20, 30] # Retry intervals
timeouts:
connect: 10 # Connection timeout
request: 60 # Request timeout
Static Resource Serving #
Place files in the app's assets/static/ folder for local serving:
assets/
├── static/
│ ├── app.css # Served at http://127.0.0.1:port/app.css
│ ├── app.js # Served at http://127.0.0.1:port/app.js
│ └── images/
│ └── logo.png # Served at http://127.0.0.1:port/images/logo.png
└── config/
└── config.yaml
Cache Management #
// Clear all cache
await proxy.clearCache();
// Clear only expired cache
await proxy.clearExpiredCache();
// Clear cache for specific URL
await proxy.clearCacheForUrl('https://api.example.com/data');
// Get cache statistics
final stats = await proxy.getCacheStats();
print('Cache hit rate: ${stats.hitRate}%');
// Get cache list
final cacheList = await proxy.getCacheList();
for (final entry in cacheList) {
print('URL: ${entry.url}, Status: ${entry.status}');
}
// Pre-warm cache
final result = await proxy.warmupCache(
paths: ['/config', '/user/profile'],
onProgress: (completed, total) {
print('Progress: $completed/$total');
},
);
Cookie Management #
// Get cookie list (values are masked)
final cookies = await proxy.getCookies();
for (final cookie in cookies) {
print('${cookie.name}: ${cookie.value} (${cookie.domain})');
}
// Get the Cookie header value for a target URL
final cookieHeader =
await proxy.getCookieHeaderForUrl('https://api.example.com/app/api');
if (cookieHeader != null) {
print('Cookie: $cookieHeader');
}
// Note: getCookieHeaderForUrl only allows URLs in the same origin as the configured origin
// Resolve a proxy URL or same-origin upstream URL before WebView navigation
final upstreamUrl = proxy.tryResolveUpstreamUrl(
'http://127.0.0.1:$proxyPort/app/map?mode=car',
);
// Resolve relative links, redirects, and new-window targets with metadata
final navigation = proxy.resolveNavigationTarget(
targetUrl: 'tel:+81012345678',
sourceUrl: 'http://127.0.0.1:$proxyPort/app/orders/detail',
);
if (navigation.disposition == ProxyNavigationDisposition.external) {
print('Delegate externally: ${navigation.normalizedTargetUri}');
}
// Restore cookies before starting the proxy
await proxy.restoreCookies([
CookieRestoreEntry.fromSetCookieHeader(
setCookieHeader: 'SESSION=abc123; Path=/; Secure; HttpOnly',
requestUrl: 'https://api.example.com/login',
),
]);
// Clear all cookies
await proxy.clearCookies();
// Clear cookies for specific domain
await proxy.clearCookies(domain: 'example.com');
Queue Management #
// Check queued requests
final queued = await proxy.getQueuedRequests();
print('Queued: ${queued.length} requests');
// Get dropped request history
final dropped = await proxy.getDroppedRequests();
for (final request in dropped) {
print('${request.url}: ${request.dropReason}');
}
// Clear drop history
await proxy.clearDroppedRequests();
Real-time Monitoring #
// Monitor proxy events
proxy.events.listen((event) {
switch (event.type) {
case ProxyEventType.cacheHit:
print('Cache hit: ${event.url}');
break;
case ProxyEventType.requestQueued:
print('Queued: ${event.url}');
break;
case ProxyEventType.queueDrained:
print('Queue drained: ${event.url}');
break;
}
});
// Get statistics
final stats = await proxy.getStats();
print('Total requests: ${stats.totalRequests}');
print('Cache hit rate: ${stats.cacheHitRate}%');
print('Uptime: ${stats.uptime}');
Architecture #
Communication Flow #
WebView → http://127.0.0.1:<port> → OfflineWebProxy
↓
[Online Check]
↓
┌──────────────────────┐
│ │
[Online] [Offline]
│ │
↓ ↓
┌─────────────┐ ┌─────────────┐
│Forward to │ │Serve from │
│upstream │ │cache │
└─────────────┘ └─────────────┘
│ │
↓ ↓
┌─────────────┐ ┌─────────────┐
│Save response│ │Queue │
│to cache │ │POST/PUT/ │
└─────────────┘ │DELETE │
└─────────────┘
Cache Strategy #
- Fresh: Within TTL → Use directly
- Stale: TTL expired but within stale period
- Online: Validate with conditional requests
- Offline: Use stale cache
- Expired: Stale period also exceeded → Deletion target
Security #
- Local Binding: Bind only to 127.0.0.1 to prevent external access
- Cookie Encryption: Persist cookies with AES-256 encryption
- Key Loss Behavior: If the secure-storage key is lost, existing encrypted cookies become unreadable and re-authentication is required
- Path Traversal Prevention: Restrict access to
assets/static/subdirectory - Log Masking: Mask sensitive information like Authorization and Cookie headers
Platform Support #
iOS Configuration #
Add ATS exception to ios/Runner/Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
Android Configuration #
Create android/app/src/main/res/xml/network_security_config.xml:
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="false">127.0.0.1</domain>
</domain-config>
</network-security-config>
Add to android/app/src/main/AndroidManifest.xml:
<application
android:networkSecurityConfig="@xml/network_security_config">
License #
MIT License
Dependencies #
This plugin uses the following packages:
- shelf - HTTP server framework
- shelf_proxy - Proxy functionality
- shelf_router - Routing
- connectivity_plus - Network status monitoring
- hive - Database (SQLite alternative)
- path_provider - File path access
Support #
Please report bugs and feature requests to GitHub Issues.
Developer Guide #
Debug Features #
Debug features available during development:
debug:
enableAdminApi: true # Enable admin API
cacheInspection: true # Cache content inspection
detailedHeaders: true # Detailed header information
Note: Always set to false in production environments.
Log Level #
logging:
level: "debug" # debug/info/warn/error
maskSensitiveHeaders: true # Mask sensitive information
Performance Monitoring #
// Periodic statistics collection
Timer.periodic(Duration(minutes: 5), (timer) async {
final stats = await proxy.getStats();
print('Cache hit rate: ${stats.cacheHitRate}%');
print('Queue length: ${stats.queueLength}');
});
Git Hooks #
Use the Git native pre-commit hook to catch formatting and analyzer issues before creating a commit.
Set it up after the Flutter SDK is available and flutter pub get has completed.
Set it up once after cloning:
git config core.hooksPath .githooks
If needed on macOS or Linux, grant execute permission:
chmod +x .githooks/pre-commit
The pre-commit hook performs the following steps:
dart fix --applydart format .- Stop the commit if any Dart file was auto-fixed or reformatted, so you can review and stage the result
dart analyze --fatal-warnings
If the commit is blocked, review the diff, run git add for the updated files, and fix analyzer warnings before trying again.
VS Code Workspace Settings #
This repository includes workspace settings for Dart files in VS Code.
- Format on save is enabled for Dart files
source.fixAllruns on explicit save for fixable warningssource.organizeImportsruns on explicit save
If you use VS Code with the Dart extension, many trivial lint issues will be fixed before commit.