Skip to content

Commit 83582f7

Browse files
bfollingtonclaude
andauthored
Implement new fetchProgram built-in using HTTPProgramResolver (commontoolsinc#2049)
* Implement fetchProgram builtin for URL-based program execution Add new fetchProgram builtin that enables patterns to fetch and resolve programs from URLs, composing cleanly with the existing compileAndRun builtin. Implementation: - Created fetch-program.ts builtin following fetchData pattern - Uses HttpProgramResolver to fetch main file and resolve dependencies - Uses resolveProgram to handle multi-file imports - Returns { pending, result: { files, main }, error } structure - Registered in builtins/index.ts - Added type definitions to api/index.ts - Added builder API in builder/built-in.ts - Created test pattern in patterns/fetch-program-test.tsx Usage example: const { result: program } = fetchProgram({ url }); const { result } = compileAndRun({ ...program, input: data }); This enables loading and executing patterns from GitHub URLs or any HTTP-accessible location, supporting the goal of distributing patterns as public, reusable components. * Refactor: Extract shared fetch utilities to eliminate duplication Extracted common fetch-related utilities (tryClaimMutex, tryWriteResult, computeInputHash, internalSchema) into a shared fetch-utils.ts module. Changes: - Created packages/runner/src/builtins/fetch-utils.ts with shared utilities - Updated fetch-data.ts to import from fetch-utils.ts - Updated fetch-program.ts to import from fetch-utils.ts - Eliminated ~100 lines of duplicated code Benefits: - DRY principle - single source of truth for fetch patterns - Easier maintenance - changes only need to be made once - Consistent behavior across all fetch-style builtins - Makes fetch-program.ts configurable with longer timeout (10s vs 5s) The shared utilities handle: - Input hashing for change detection - Mutex claiming for multi-tab coordination - Result writing with input validation * Simplify fetchProgram - remove unnecessary complexity Stripped down fetchProgram from ~300 lines to ~115 lines by removing: - Mutex/multi-tab coordination (over-engineered for initial version) - Input hashing and change detection - AbortController logic - All the shared fetch-utils machinery Core logic is now clear: 1. Initialize cells for pending/result/error 2. Fetch URL and resolve program dependencies 3. Update cells with result or error The builtin now does exactly what it needs to: - Fetch a program from a URL using HttpProgramResolver - Resolve all dependencies via resolveProgram - Return { files, main } structure for compileAndRun Can always add optimizations later if needed. * Use shared fetch utilities in fetchProgram for consistency Updated fetchProgram to use the same fetch-utils.ts patterns as fetchData: - Multi-tab coordination via tryClaimMutex (prevents duplicate fetches) - Input hashing via computeInputHash (detects URL changes) - Safe result writing via tryWriteResult (any tab can write if inputs match) - AbortController support for cancellation - Proper cleanup on recipe stop This ensures both fetch-style builtins follow the same pattern for: - Avoiding duplicate network requests across tabs - Handling race conditions properly - Consistent timeout and retry behavior fetchProgram uses 10s timeout (vs fetchData's 5s) since program resolution with dependencies can take longer than simple HTTP fetches. * It's alive! * Format pass * Fix lint + format * Exclude result from hash computation * Tighten retry and de-duplication logic * Clear result if input to fetchProgram cleared * Format + lint * Fix tests * Address code review * Action code review feedback * One more pass over the state machine --------- Co-authored-by: Claude <[email protected]>
1 parent 9f75a14 commit 83582f7

File tree

11 files changed

+626
-161
lines changed

11 files changed

+626
-161
lines changed

packages/api/index.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,17 @@ export type FetchDataFunction = <T>(
11831183
}>,
11841184
) => OpaqueRef<{ pending: boolean; result: T; error: any }>;
11851185

1186+
export type FetchProgramFunction = (
1187+
params: Opaque<{ url: string }>,
1188+
) => OpaqueRef<{
1189+
pending: boolean;
1190+
result: {
1191+
files: Array<{ name: string; contents: string }>;
1192+
main: string;
1193+
} | undefined;
1194+
error: any;
1195+
}>;
1196+
11861197
export type StreamDataFunction = <T>(
11871198
params: Opaque<{
11881199
url: string;
@@ -1255,6 +1266,7 @@ export declare const llmDialog: LLMDialogFunction;
12551266
export declare const generateObject: GenerateObjectFunction;
12561267
export declare const generateText: GenerateTextFunction;
12571268
export declare const fetchData: FetchDataFunction;
1269+
export declare const fetchProgram: FetchProgramFunction;
12581270
export declare const streamData: StreamDataFunction;
12591271
export declare const compileAndRun: CompileAndRunFunction;
12601272
export declare const navigateTo: NavigateToFunction;

packages/js-runtime/typescript/mod.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,9 @@ export {
77
type CompilationErrorType,
88
CompilerError,
99
} from "./diagnostics/mod.ts";
10-
export { getCompilerOptions } from "./options.ts";
10+
export { getCompilerOptions, TARGET } from "./options.ts";
11+
export {
12+
type ResolveModuleConfig,
13+
resolveProgram,
14+
type UnresolvedModuleHandling,
15+
} from "./resolver.ts";
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/// <cts-enable />
2+
import {
3+
cell,
4+
compileAndRun,
5+
derive,
6+
fetchProgram,
7+
NAME,
8+
recipe,
9+
UI,
10+
} from "commontools";
11+
12+
/**
13+
* Test pattern for fetchProgram builtin.
14+
* Fetches a program from a URL and compiles it.
15+
*/
16+
export default recipe("Fetch Program Test", () => {
17+
// URL to a simple pattern file
18+
const url = cell(
19+
"https://raw.githubusercontent.com/commontoolsinc/labs/main/packages/patterns/counter.tsx",
20+
);
21+
22+
// Step 1: Fetch the program from URL
23+
const { pending: fetchPending, result: program, error: fetchError } =
24+
fetchProgram({ url });
25+
26+
// Step 2: Compile and run the fetched program
27+
// Explicitly map program fields to compileAndRun params
28+
const compileParams = derive(program, (p) => ({
29+
files: p?.files ?? [],
30+
main: p?.main ?? "",
31+
input: { value: 10 },
32+
}));
33+
const { pending: compilePending, result, error: compileError } =
34+
compileAndRun(compileParams);
35+
36+
return {
37+
[NAME]: "Fetch Program Test",
38+
[UI]: (
39+
<div>
40+
<h1>Fetch Program Test</h1>
41+
<div>
42+
<label>URL:</label>
43+
<ct-input type="text" $value={url} />
44+
</div>
45+
{fetchPending && <div>Fetching program...</div>}
46+
{compilePending && <div>Compiling...</div>}
47+
{fetchError && <div style="color: red">Fetch error: {fetchError}</div>}
48+
{compileError && (
49+
<div style="color: red">Compile error: {compileError}</div>
50+
)}
51+
{result && (
52+
<div style="color: green">
53+
Successfully compiled recipe! Charm ID: {result}
54+
<pre>{derive(result, r => JSON.stringify(r, null, 2))}</pre>
55+
</div>
56+
)}
57+
</div>
58+
),
59+
url,
60+
program,
61+
result,
62+
};
63+
});

packages/runner/src/builder/built-in.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,20 @@ export const fetchData = createNodeFactory({
7171
}>,
7272
) => OpaqueRef<{ pending: boolean; result: T; error: unknown }>;
7373

74+
export const fetchProgram = createNodeFactory({
75+
type: "ref",
76+
implementation: "fetchProgram",
77+
}) as (
78+
params: Opaque<{ url: string }>,
79+
) => OpaqueRef<{
80+
pending: boolean;
81+
result: {
82+
files: Array<{ name: string; contents: string }>;
83+
main: string;
84+
} | undefined;
85+
error: unknown;
86+
}>;
87+
7488
export const streamData = createNodeFactory({
7589
type: "ref",
7690
implementation: "streamData",

packages/runner/src/builder/factory.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { byRef, computed, derive, handler, lift } from "./module.ts";
2727
import {
2828
compileAndRun,
2929
fetchData,
30+
fetchProgram,
3031
generateObject,
3132
generateText,
3233
ifElse,
@@ -92,6 +93,7 @@ export const createBuilder = (): {
9293
generateObject,
9394
generateText,
9495
fetchData,
96+
fetchProgram,
9597
streamData,
9698
compileAndRun,
9799
navigateTo,

packages/runner/src/builder/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
ComputedFunction,
1616
DeriveFunction,
1717
FetchDataFunction,
18+
FetchProgramFunction,
1819
GenerateObjectFunction,
1920
GenerateTextFunction,
2021
GetRecipeEnvironmentFunction,
@@ -254,6 +255,7 @@ export interface BuilderFunctionsAndConstants {
254255
generateObject: GenerateObjectFunction;
255256
generateText: GenerateTextFunction;
256257
fetchData: FetchDataFunction;
258+
fetchProgram: FetchProgramFunction;
257259
streamData: StreamDataFunction;
258260
compileAndRun: CompileAndRunFunction;
259261
navigateTo: NavigateToFunction;

packages/runner/src/builtins/compile-and-run.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,27 @@ export function compileAndRun(
140140
// as previousCallHash) or when rehydrated from storage (same as the
141141
// contents of the requestHash doc).
142142
if (hash === previousCallHash) return;
143+
144+
// Check if inputs are undefined/empty (e.g., during rehydration before cells load)
145+
const hasValidInputs = program.main && program.files &&
146+
program.files.length > 0;
147+
148+
// Special case: if inputs are invalid AND this is the hash for empty inputs,
149+
// the user intentionally cleared them - proceed to clear outputs
150+
const emptyInputsHash = refer({ files: [], main: "" }).toString();
151+
const isIntentionallyEmpty = !hasValidInputs && hash === emptyInputsHash;
152+
153+
// If we have a previous valid result and inputs are currently invalid (likely rehydrating),
154+
// don't clear the outputs - just wait for real inputs to load
155+
// BUT if inputs are intentionally empty, we should clear
156+
if (
157+
!hasValidInputs && previousCallHash && previousCallHash !== hash &&
158+
!isIntentionallyEmpty
159+
) {
160+
// Don't update previousCallHash - we'll wait for valid inputs
161+
return;
162+
}
163+
143164
previousCallHash = hash;
144165

145166
// Abort any in-flight compilation before starting a new one
@@ -153,7 +174,7 @@ export function compileAndRun(
153174
errorsWithLog.set(undefined);
154175

155176
// Undefined inputs => Undefined output, not pending
156-
if (!program.main || !program.files) {
177+
if (!hasValidInputs) {
157178
pendingWithLog.set(false);
158179
return;
159180
}

0 commit comments

Comments
 (0)