Skip to content

Commit 9f5c371

Browse files
CHORE: added documentation for streaming utils and made timeouts configurable (#180)
1 parent 1d2f1ab commit 9f5c371

12 files changed

Lines changed: 204 additions & 20 deletions

frontend/src/app/[locale]/generate-referrals/page.tsx

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,46 @@ export default function Page() {
4949
const [showResultsView, setShowResultsView] = useState(false);
5050

5151
// ========== Resources Streaming (Custom Hook) ==========
52+
/**
53+
* STREAMING STATE LIFECYCLE:
54+
*
55+
* The streaming process follows these state transitions:
56+
*
57+
* 1. INITIAL STATE (before search):
58+
* - loading: false
59+
* - isStreamingResources: false
60+
* - hasReceivedFirstResource: false
61+
* - streamingResources: null
62+
* - retainedResources: undefined
63+
*
64+
* 2. LOADING STATE (search initiated):
65+
* - loading: true (triggers loading UI)
66+
* - isStreamingResources: true
67+
* - showResultsView: true
68+
* - Starts 12-second timeout to show "No resources found" if nothing arrives
69+
*
70+
* 3. STREAMING STATE (first chunk arrives):
71+
* - hasReceivedFirstResource: true (clears "No resources found" timeout)
72+
* - streamingResources: PartialResource[] (updates with each chunk)
73+
* - ResourcesList displays streamingResources with loading skeleton for incomplete data
74+
*
75+
* 4. STREAM COMPLETE STATE:
76+
* - isStreamingResources: false
77+
* - streamingResources: null (cleared)
78+
* - retainedResources: Resource[] (final validated resources)
79+
* - ResourcesList switches to displaying retainedResources
80+
*
81+
* 5. ERROR STATE (at any point):
82+
* - isStreamingResources: false
83+
* - loading: false
84+
* - streamingResources: null
85+
* - errorMessage: string (displayed in ResourcesList)
86+
*
87+
* State management notes:
88+
* - streamingResources are partial/incomplete, retainedResources are final/complete
89+
* - retainedResources is user-editable (can remove items), streamingResources is read-only
90+
* - The hook manages streaming lifecycle, this component manages retained results
91+
*/
5292
const {
5393
loading,
5494
isStreamingResources,

frontend/src/app/api/fcc-lookup/route.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextResponse } from "next/server";
2+
import { LOCATION_FETCH_TIMEOUT } from "@/config/timeouts";
23

34
interface FCCCounty {
45
FIPS: string;
@@ -45,8 +46,7 @@ export async function GET(request: Request) {
4546
const fccUrl = `https://geo.fcc.gov/api/census/block/find?${params.toString()}`;
4647

4748
const fccResponse = await fetch(fccUrl, {
48-
// Add a timeout
49-
signal: AbortSignal.timeout(5000),
49+
signal: AbortSignal.timeout(LOCATION_FETCH_TIMEOUT), //5000
5050
});
5151
if (!fccResponse.ok) {
5252
return NextResponse.json(

frontend/src/hooks/useResourceRemoval.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useCallback } from "react";
22
import { Resource } from "@/types/resources";
3+
import { UNDO_NOTIFICATION_TIMEOUT } from "@/config/timeouts";
34

45
export interface UseResourceRemovalReturn {
56
recentlyRemoved: Resource | null;
@@ -34,7 +35,7 @@ export function useResourceRemoval(): UseResourceRemovalReturn {
3435

3536
setRecentlyRemoved(resourceToRemove);
3637

37-
// Auto-clear the undo notification after 7.5 seconds
38+
// Auto-clear the undo notification after timeout period
3839
setTimeout(() => {
3940
setRecentlyRemoved((current) => {
4041
// If the resource is still marked as recently removed, clear it
@@ -44,7 +45,7 @@ export function useResourceRemoval(): UseResourceRemovalReturn {
4445
return current;
4546
});
4647
setRemovedResourceIndex(null);
47-
}, 7500);
48+
}, UNDO_NOTIFICATION_TIMEOUT); //7500
4849
},
4950
[],
5051
);

frontend/src/hooks/useResourcesStreaming.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
buildRequestWithResolvedZipCodes,
77
RequestParams,
88
} from "@/util/resolveZipCodes";
9+
import { NO_RESOURCES_TIMEOUT } from "@/config/timeouts";
910

1011
export interface UseResourcesStreamingReturn {
1112
loading: boolean;
@@ -62,14 +63,14 @@ export function useResourcesStreaming(): UseResourcesStreamingReturn {
6263
setStreamingResources(null);
6364
setErrorMessage(undefined);
6465

65-
// 12-second timeout to show "No resources found" if nothing arrives
66+
// Show "No resources found" if nothing arrives within timeout period - 12 seconds
6667
const timeoutId = setTimeout(() => {
6768
if (!hasReceivedFirstResourceRef.current) {
6869
setErrorMessage("No resources found.");
6970
setIsStreamingResources(false);
7071
setLoading(false);
7172
}
72-
}, 12000);
73+
}, NO_RESOURCES_TIMEOUT);
7374

7475
try {
7576
const request = await buildRequestWithResolvedZipCodes(params);

frontend/src/util/createStreamingFetcher.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
* Handles abort controllers, timeouts, and SSE parsing.
44
*/
55

6+
import { STREAMING_TIMEOUT } from "@/config/timeouts";
7+
68
export interface StreamingConfig<T, TPartial> {
79
/** The API endpoint URL */
810
url: string;
9-
/** Timeout in milliseconds (default: 600000 = 10 minutes) */
11+
/** Timeout in milliseconds (default: STREAMING_TIMEOUT = 10 minutes) */
1012
timeout?: number;
1113
/** Request body to send */
1214
requestBody: Record<string, unknown>;
@@ -39,7 +41,7 @@ export async function createStreamingFetcher<T, TPartial>(
3941
): Promise<StreamingResult<T>> {
4042
const {
4143
url,
42-
timeout = 600_000,
44+
timeout = STREAMING_TIMEOUT,
4345
requestBody,
4446
onChunk,
4547
onComplete,

frontend/src/util/emailResult.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
EmailResultResponse,
55
EmailFullResultResponse,
66
} from "@/types/api";
7+
import { EMAIL_TIMEOUT } from "@/config/timeouts";
78

89
export async function emailResult(
910
resultId: string,
@@ -33,7 +34,7 @@ export async function emailResult(
3334
};
3435

3536
const ac = new AbortController();
36-
const timer = setTimeout(() => ac.abort(), 300_000);
37+
const timer = setTimeout(() => ac.abort(), EMAIL_TIMEOUT); //300_000
3738

3839
try {
3940
const response = await fetch(url, {

frontend/src/util/fetchActionPlan.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { getApiDomain } from "./apiDomain";
33
import { fixJsonControlCharacters, extractField } from "./parseStreamingUtils";
44
import { createStreamingFetcher } from "./createStreamingFetcher";
55
import { GenerateActionPlanResponse } from "@/types/api";
6+
import { ACTION_PLAN_TIMEOUT, STREAMING_TIMEOUT } from "@/config/timeouts";
67

78
export interface ActionPlan {
89
title: string;
@@ -45,7 +46,7 @@ export async function fetchActionPlan(
4546
};
4647

4748
const ac = new AbortController();
48-
const timer = setTimeout(() => ac.abort(), 120_000);
49+
const timer = setTimeout(() => ac.abort(), ACTION_PLAN_TIMEOUT); //120_000
4950

5051
try {
5152
const requestBody: {
@@ -135,7 +136,7 @@ export async function fetchActionPlanStreaming(
135136

136137
const result = await createStreamingFetcher<ActionPlan, PartialActionPlan>({
137138
url,
138-
timeout: 600_000, // 10 minutes
139+
timeout: STREAMING_TIMEOUT, // 10 minutes
139140
requestBody: {
140141
model: "generate_action_plan", // Pipeline name as model
141142
messages: [{ role: "user", content: userQuery }],

frontend/src/util/fetchResourcesStreaming.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { fixJsonControlCharacters } from "./parseStreamingUtils";
88
import { ResourcesSchema } from "@/types/resources";
99
import { createStreamingFetcher } from "./createStreamingFetcher";
10+
import { STREAMING_TIMEOUT } from "@/config/timeouts";
1011

1112
/**
1213
* Fetches resources with streaming support using Server-Sent Events (SSE).
@@ -58,7 +59,7 @@ export async function fetchResourcesStreaming(
5859

5960
const result = await createStreamingFetcher<Resource[], PartialResource[]>({
6061
url,
61-
timeout: 600_000, // 10 minutes
62+
timeout: STREAMING_TIMEOUT, // 10 minutes
6263
requestBody,
6364
onChunk,
6465
onComplete,

frontend/src/util/parseStreamingResources.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,27 @@ export function parseStreamingResources(jsonStr: string): PartialResource[] {
2727
const arrayStartPos =
2828
resourcesKeyMatch.index! + resourcesKeyMatch[0].length;
2929

30-
// Manually find the matching closing bracket by tracking depth
30+
/**
31+
* BRACKET DEPTH TRACKING ALGORITHM
32+
*
33+
* This section finds the matching closing bracket for the "resources" array
34+
* by maintaining a depth counter. This is necessary because:
35+
* 1. The array may contain nested arrays (e.g., addresses, phones)
36+
* 2. We need to handle incomplete streaming JSON (no closing bracket yet)
37+
* 3. We must ignore brackets inside string values
38+
*
39+
* Algorithm:
40+
* - bracketDepth starts at 1 (we're already inside the resources array)
41+
* - For each '[', increment depth (entering nested array)
42+
* - For each ']', decrement depth (exiting nested array)
43+
* - When depth reaches 0, we've found the matching closing bracket
44+
* - If we reach end of string with depth > 0, the array is incomplete (streaming)
45+
*
46+
* String state tracking (inString flag):
47+
* - Prevents counting brackets that appear inside string values
48+
* - Example: "address": "123 Main St [Apt 4]" should not affect depth
49+
* - Toggles on/off when encountering unescaped quotes (not preceded by \)
50+
*/
3151
let bracketDepth = 1; // We're already inside the resources array
3252
let inString = false;
3353
let prevChar = "";
@@ -61,8 +81,29 @@ export function parseStreamingResources(jsonStr: string): PartialResource[] {
6181
// the closing bracket or the end of the string for incomplete streams)
6282
const arrayContent = jsonStr.substring(arrayStartPos, arrayEndPos);
6383

64-
// Find all resource object boundaries by tracking brace depth
65-
// We want to identify each `{...}` at the top level of the array
84+
/**
85+
* RESOURCE OBJECT BOUNDARY DETECTION
86+
*
87+
* This section identifies individual resource objects within the array by
88+
* tracking brace depth. Similar to bracket tracking above, but for objects.
89+
*
90+
* We want to identify each top-level `{...}` in the array:
91+
* Example: [{"name": "A"}, {"name": "B", "addresses": ["X", "Y"]}, {"name"
92+
* ^-- resource 1 --^ ^-- resource 2 (has nested array) ----^ ^-- incomplete
93+
*
94+
* Algorithm:
95+
* - braceDepth = 0 means we're between objects
96+
* - When braceDepth goes from 0→1, we mark the start of a new resource
97+
* - When braceDepth goes from 1→0, we mark the end of that resource
98+
* - Nested objects (e.g., in a field value) don't trigger new resources
99+
* because we only care about 0→1 transitions
100+
*
101+
* Handling incomplete resources:
102+
* - If we reach end of array content with braceDepth > 0, the last resource
103+
* is incomplete (still streaming from server)
104+
* - We include it anyway so users see partial data during streaming
105+
* - parsePartialResource handles incomplete JSON gracefully
106+
*/
66107
const resourceRanges: Array<{ start: number; end: number }> = [];
67108
let braceDepth = 0;
68109
inString = false; // Reset the string tracking flag

frontend/src/util/parseStreamingUtils.ts

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,38 @@ export function fixJsonControlCharacters(jsonString: string): string {
5858

5959
/**
6060
* Extracts a field value from partial JSON, handling incomplete strings
61-
* Pattern matches escaped characters: (?:[^"\\]|\\.)*
62-
* - [^"\\] matches any char except quote or backslash
63-
* - \\. matches backslash followed by any char (handles \", \n, etc)
61+
*
62+
* Uses regex to find string field values in streaming JSON, even when the closing
63+
* quote hasn't arrived yet. Handles escaped characters properly.
64+
*
65+
* **Pattern explanation:** `(?:[^"\\]|\\.)*`
66+
* - `[^"\\]` matches any char except quote or backslash
67+
* - `\\.` matches backslash followed by any char (handles \", \n, etc)
68+
* - `(?:...)` non-capturing group
69+
* - `*` matches zero or more times
70+
*
71+
* **Examples:**
72+
* ```typescript
73+
* // Complete field
74+
* extractField('{"name": "John Doe"}', 'name')
75+
* // → "John Doe"
76+
*
77+
* // Incomplete field (no closing quote yet)
78+
* extractField('{"name": "John Do', 'name')
79+
* // → "John Do"
80+
*
81+
* // Escaped characters
82+
* extractField('{"description": "Line 1\\nLine 2"}', 'description')
83+
* // → "Line 1\nLine 2" (unescaped)
84+
*
85+
* // Escaped quotes in value
86+
* extractField('{"text": "He said \\"Hello\\""}', 'text')
87+
* // → "He said \"Hello\""
88+
*
89+
* // Field not found
90+
* extractField('{"name": "John"}', 'age')
91+
* // → undefined
92+
* ```
6493
*/
6594
export function extractField(
6695
jsonStr: string,
@@ -87,7 +116,40 @@ export function extractField(
87116

88117
/**
89118
* Extracts array items from partial JSON
90-
* Handles both complete and incomplete array strings
119+
*
120+
* Handles both complete and incomplete array strings, finding all complete items
121+
* and optionally the incomplete last item (string without closing quote).
122+
*
123+
* Uses two regex patterns:
124+
* 1. Global pattern to find all complete items: `/"((?:[^"\\]|\\.)*)"/g`
125+
* 2. Anchored pattern to find incomplete last item: `/"((?:[^"\\]|\\.)*?)$/`
126+
*
127+
* **Examples:**
128+
* ```typescript
129+
* // Complete array
130+
* extractArrayField('{"phones": ["555-1234", "555-5678"]}', 'phones')
131+
* // → ["555-1234", "555-5678"]
132+
*
133+
* // Incomplete array (no closing bracket)
134+
* extractArrayField('{"phones": ["555-1234", "555-5678"', 'phones')
135+
* // → ["555-1234", "555-5678"]
136+
*
137+
* // Incomplete last item (no closing quote)
138+
* extractArrayField('{"phones": ["555-1234", "555-56', 'phones')
139+
* // → ["555-1234", "555-56"]
140+
*
141+
* // Escaped characters in items
142+
* extractArrayField('{"lines": ["Line 1\\nLine 2", "Line 3"]}', 'lines')
143+
* // → ["Line 1\nLine 2", "Line 3"]
144+
*
145+
* // Empty array
146+
* extractArrayField('{"phones": []}', 'phones')
147+
* // → []
148+
*
149+
* // Array not found
150+
* extractArrayField('{"name": "John"}', 'phones')
151+
* // → []
152+
* ```
91153
*/
92154
export function extractArrayField(
93155
jsonStr: string,

0 commit comments

Comments
 (0)