Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 131 additions & 13 deletions client_sdks/devconnect-react-native/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,83 @@ function classifyUrl(url: string): string {
return 'app';
}

function wrapFetchInit(
init: RequestInit | undefined,
tracker: { headers?: any; body?: any },
): RequestInit | undefined {
if (!init) return init;

const headers = init.headers;
if (headers && typeof headers === 'object' && !(headers instanceof Headers)) {
Comment on lines +389 to +390

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

The check typeof headers === 'object' evaluates to true for arrays. If headers is passed as an array of key-value pairs (e.g., string[][]), it will incorrectly enter this block and be wrapped in a Proxy designed for a flat record. This will corrupt the headers when they are spread or read.

Adding !Array.isArray(headers) ensures that array-based headers are correctly handled by the fallback block, which is already supported by readFinalHeaders.

Suggested change
const headers = init.headers;
if (headers && typeof headers === 'object' && !(headers instanceof Headers)) {
const headers = init.headers;
if (headers && typeof headers === 'object' && !Array.isArray(headers) && !(headers instanceof Headers)) {

const proxy = new Proxy(headers as Record<string, string>, {
set(target, key, value) {
(target as any)[key] = value;
tracker.headers = { ...target };
return true;
},
deleteProperty(target, key) {
delete (target as any)[key];
tracker.headers = { ...target };
return true;
},
});
(init as any).headers = proxy;
tracker.headers = { ...(headers as Record<string, string>) };
} else if (headers instanceof Headers) {
const origAppend = headers.append.bind(headers);
const origSet = headers.set.bind(headers);
const origDelete = headers.delete.bind(headers);
headers.append = function (name: string, value: string) {
const r = origAppend(name, value);
const snap: Record<string, string> = {};
headers.forEach((v, k) => (snap[k.toLowerCase()] = v));
tracker.headers = snap;
return r;
};
headers.set = function (name: string, value: string) {
const r = origSet(name, value);
const snap: Record<string, string> = {};
headers.forEach((v, k) => (snap[k.toLowerCase()] = v));
tracker.headers = snap;
return r;
};
headers.delete = function (name: string) {
const r = origDelete(name);
const snap: Record<string, string> = {};
headers.forEach((v, k) => (snap[k.toLowerCase()] = v));
tracker.headers = snap;
return r;
};
const snap: Record<string, string> = {};
headers.forEach((v, k) => (snap[k.toLowerCase()] = v));
tracker.headers = snap;
} else {
tracker.headers = headers;
}

if ('body' in init) tracker.body = init.body;
(init as any)['__dcBodyGet'] = () => tracker.body;

return init;
}

function readFinalHeaders(
tracker: { headers?: any },
init: RequestInit | undefined,
): Record<string, string> {
const out: Record<string, string> = {};
const src = tracker.headers ?? init?.headers;
if (!src) return out;
if (typeof Headers !== 'undefined' && src instanceof Headers) {
src.forEach((v, k) => (out[k.toLowerCase()] = v));
} else if (Array.isArray(src)) {
for (const [k, v] of src) out[String(k).toLowerCase()] = String(v);
} else if (typeof src === 'object') {
for (const [k, v] of Object.entries(src)) out[k.toLowerCase()] = String(v);
}
return out;
}

// ---- Main Class ----

export class DevConnect {
Expand Down Expand Up @@ -752,24 +829,60 @@ export class DevConnect {
global.fetch = async function (input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const requestId = generateId();
const startTime = Date.now();
const method = init?.method?.toUpperCase() ?? 'GET';
const url = typeof input === 'string' ? input : input.toString();

const reqHeaders: Record<string, string> = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => (reqHeaders[k] = v));
} else if (typeof init.headers === 'object') {
Object.entries(init.headers).forEach(([k, v]) => (reqHeaders[k] = String(v)));
let method: string;
if (init?.method) {
method = init.method.toUpperCase();
} else if (typeof Request !== 'undefined' && input instanceof Request) {
method = (input as Request).method.toUpperCase();
} else {
method = 'GET';
}

let url: string;
if (typeof input === 'string') {
url = input;
} else if (input instanceof URL) {
url = input.toString();
} else if (typeof Request !== 'undefined' && input instanceof Request) {
url = input.url;
} else {
url = (input as any)?.url ?? String(input);
}

// AWS SDK v3 (@aws-sdk/fetch-http-handler) calls `fetch(request)` where
// `request` is a fully-built Request object — init is undefined in that
// case. All headers (including X-Amz-Date, Authorization added by the
// signer middleware) are inside request.headers by the time fetch is
// invoked. We must read from request, not init.
const isRequestInput =
typeof Request !== 'undefined' && input instanceof Request;
let reqHeaders: Record<string, string>;
let finalBody: any;

if (isRequestInput) {
const r = input as Request;
reqHeaders = {};
r.headers.forEach((v, k) => (reqHeaders[k.toLowerCase()] = v));
try {
finalBody = await r.clone().text();
} catch (_) {
finalBody = undefined;
}
} else {
const tracker: { headers?: any; body?: any } = {};
const trackedInit = wrapFetchInit(init, tracker);
(init as any) = trackedInit;
reqHeaders = readFinalHeaders(tracker, trackedInit);
finalBody = tracker.body !== undefined ? tracker.body : trackedInit?.body;
}

let requestBody: any;
if (init?.body) {
if (init.body instanceof FormData) {
if (finalBody) {
if (finalBody instanceof FormData) {
const fields: Record<string, any> = {};
const files: any[] = [];
for (const [key, value] of (init.body as any).entries()) {
for (const [key, value] of (finalBody as any).entries()) {
if (value instanceof Blob || (value && typeof value === 'object' && value.uri)) {
files.push({ key, filename: value.name ?? value.filename ?? 'unknown', type: value.type ?? value.contentType ?? 'unknown', size: value.size ?? value.length });
} else {
Expand All @@ -778,7 +891,7 @@ export class DevConnect {
}
requestBody = { ...fields, ...(files.length ? { _files: files, _contentType: 'multipart/form-data' } : {}) };
} else {
try { requestBody = JSON.parse(init.body as string); } catch (_) { requestBody = String(init.body); }
try { requestBody = JSON.parse(finalBody as string); } catch (_) { requestBody = String(finalBody); }
}
}

Expand Down Expand Up @@ -825,7 +938,12 @@ export class DevConnect {
let requestBody: any;

const origOpen = xhr.open.bind(xhr);
xhr.open = (m: string, u: string, ...args: any[]) => { method = m.toUpperCase(); url = u; return origOpen(m, u, ...args); };
xhr.open = (m: string, u: string | URL, ...args: any[]) => {
method = m.toUpperCase();
// Coerce URL/Request to a string in case a polyfill accepts them
url = typeof u === 'string' ? u : (u as any)?.url ?? String(u);
return origOpen(m, u as string, ...args);
};

const origSetHeader = xhr.setRequestHeader.bind(xhr);
xhr.setRequestHeader = (n: string, v: string) => { reqHeaders[n] = v; return origSetHeader(n, v); };
Expand Down
30 changes: 30 additions & 0 deletions lib/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'components/viewers/json_viewer.dart';
import 'core/providers/locale_provider.dart';
import 'core/routes/app_router.dart';
import 'core/theme/app_theme.dart';
Expand All @@ -17,15 +18,27 @@ class DevConnectApp extends ConsumerStatefulWidget {
}

class _DevConnectAppState extends ConsumerState<DevConnectApp> {
/// Device IDs we've already seen, so a freshly-connected device (vs an
/// existing one re-emitting) is the trigger for cache invalidation.
Set<String>? _knownDeviceIds;

@override
void initState() {
super.initState();
// Auto-clear the JSON highlight cache after long background sessions.
HighlightCacheLifecycleObserver.instance.attach();
// Auto-start WebSocket server on app launch
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoStartServer();
});
}

@override
void dispose() {
HighlightCacheLifecycleObserver.instance.detach();
super.dispose();
}

Future<void> _autoStartServer() async {
final server = ref.read(wsServerProvider);
if (!server.isRunning) {
Expand Down Expand Up @@ -63,6 +76,23 @@ class _DevConnectAppState extends ConsumerState<DevConnectApp> {
// events are recorded even when no Settings page is open.
ref.watch(deviceHistoryMirrorProvider);

// A. Invalidate the JSON highlight cache when the user picks a
// different device — data is filtered per-device, so old highlights
// belong to a different payload.
ref.listen<String?>(selectedDeviceProvider, (_, next) {
HighlightCacheLifecycleObserver.instance.clearCache();
});

// B. Invalidate the JSON highlight cache when a NEW device connects.
// A reconnect of an already-known device (e.g. hot reload) does NOT
// trigger this — only an addition to the device list.
final devices = ref.watch(connectedDevicesProvider);
final ids = devices.map((d) => d.deviceId).toSet();
if (_knownDeviceIds != null && ids.any((id) => !_knownDeviceIds!.contains(id))) {
HighlightCacheLifecycleObserver.instance.clearCache();
}
_knownDeviceIds = ids;
Comment on lines +89 to +94

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Mutating state variables (like _knownDeviceIds) and triggering side effects (like clearing a cache) directly inside the build method is a Flutter anti-pattern. It can lead to inconsistent state or unnecessary rebuilds.

Instead, use ref.listen to reactively listen to changes in connectedDevicesProvider and trigger the cache invalidation side-effect. This also allows you to completely remove the _knownDeviceIds state variable from the class.

Suggested change
final devices = ref.watch(connectedDevicesProvider);
final ids = devices.map((d) => d.deviceId).toSet();
if (_knownDeviceIds != null && ids.any((id) => !_knownDeviceIds!.contains(id))) {
HighlightCacheLifecycleObserver.instance.clearCache();
}
_knownDeviceIds = ids;
ref.listen(connectedDevicesProvider, (previous, next) {
if (previous != null) {
final prevIds = previous.map((d) => d.deviceId).toSet();
final nextIds = next.map((d) => d.deviceId).toSet();
if (nextIds.any((id) => !prevIds.contains(id))) {
HighlightCacheLifecycleObserver.instance.clearCache();
}
}
});


final locale = ref.watch(localeProvider);

return MaterialApp.router(
Expand Down
Loading
Loading