Skip to content

Commit 66731b5

Browse files
committed
refactor(shared): establish lazy runtime loader foundation
1 parent d47308f commit 66731b5

5 files changed

Lines changed: 104 additions & 48 deletions

File tree

src/library.ts

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import type {
2020
runCommandWithTimeout as runCommandWithTimeoutRuntime,
2121
runExec as runExecRuntime,
2222
} from "./process/exec.js";
23+
import { createLazyRuntimeModule } from "./shared/lazy-runtime.js";
2324
import { normalizeE164 } from "./utils.js";
2425

2526
type GetReplyFromConfig = typeof getReplyFromConfigRuntime;
@@ -29,38 +30,13 @@ type RunExec = typeof runExecRuntime;
2930
type RunCommandWithTimeout = typeof runCommandWithTimeoutRuntime;
3031
type MonitorWebChannel = typeof monitorWebChannelRuntime;
3132

32-
let replyRuntimePromise: Promise<typeof import("./auto-reply/reply.runtime.js")> | null = null;
33-
let promptRuntimePromise: Promise<typeof import("./cli/prompt.js")> | null = null;
34-
let binariesRuntimePromise: Promise<typeof import("./infra/binaries.js")> | null = null;
35-
let execRuntimePromise: Promise<typeof import("./process/exec.js")> | null = null;
36-
let webChannelRuntimePromise: Promise<
37-
typeof import("./plugins/runtime/runtime-web-channel-plugin.js")
38-
> | null = null;
39-
40-
function loadReplyRuntime() {
41-
replyRuntimePromise ??= import("./auto-reply/reply.runtime.js");
42-
return replyRuntimePromise;
43-
}
44-
45-
function loadPromptRuntime() {
46-
promptRuntimePromise ??= import("./cli/prompt.js");
47-
return promptRuntimePromise;
48-
}
49-
50-
function loadBinariesRuntime() {
51-
binariesRuntimePromise ??= import("./infra/binaries.js");
52-
return binariesRuntimePromise;
53-
}
54-
55-
function loadExecRuntime() {
56-
execRuntimePromise ??= import("./process/exec.js");
57-
return execRuntimePromise;
58-
}
59-
60-
function loadWebChannelRuntime() {
61-
webChannelRuntimePromise ??= import("./plugins/runtime/runtime-web-channel-plugin.js");
62-
return webChannelRuntimePromise;
63-
}
33+
const loadReplyRuntime = createLazyRuntimeModule(() => import("./auto-reply/reply.runtime.js"));
34+
const loadPromptRuntime = createLazyRuntimeModule(() => import("./cli/prompt.js"));
35+
const loadBinariesRuntime = createLazyRuntimeModule(() => import("./infra/binaries.js"));
36+
const loadExecRuntime = createLazyRuntimeModule(() => import("./process/exec.js"));
37+
const loadWebChannelRuntime = createLazyRuntimeModule(
38+
() => import("./plugins/runtime/runtime-web-channel-plugin.js"),
39+
);
6440

6541
export const getReplyFromConfig: GetReplyFromConfig = async (...args) =>
6642
(await loadReplyRuntime()).getReplyFromConfig(...args);

src/shared/lazy-promise.test.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
// Lazy promise tests cover single-flight loading and error reuse behavior.
22
import { describe, expect, it, vi } from "vitest";
3-
import { createLazyImportLoader, createLazyPromiseLoader } from "./lazy-promise.js";
3+
import {
4+
createLazyImportLoader,
5+
createLazyPromise,
6+
createLazyPromiseLoader,
7+
} from "./lazy-promise.js";
8+
9+
describe("createLazyPromise", () => {
10+
it("returns a reusable single-flight loader", async () => {
11+
let calls = 0;
12+
const load = createLazyPromise(async () => `loaded-${++calls}`);
13+
14+
await expect(Promise.all([load(), load()])).resolves.toEqual(["loaded-1", "loaded-1"]);
15+
await expect(load()).resolves.toBe("loaded-1");
16+
expect(calls).toBe(1);
17+
});
18+
});
419

520
describe("createLazyPromiseLoader", () => {
621
it("dedupes concurrent loads and reuses the resolved value", async () => {
722
let calls = 0;
823
const loader = createLazyPromiseLoader(async () => `loaded-${++calls}`);
24+
const first = loader.load();
925

10-
await expect(Promise.all([loader.load(), loader.load()])).resolves.toEqual([
11-
"loaded-1",
12-
"loaded-1",
13-
]);
26+
expect(loader.load()).toBe(first);
27+
await expect(first).resolves.toBe("loaded-1");
1428
await expect(loader.load()).resolves.toBe("loaded-1");
1529
expect(calls).toBe(1);
1630
});
@@ -45,8 +59,11 @@ describe("createLazyPromiseLoader", () => {
4559
let calls = 0;
4660
const loader = createLazyPromiseLoader(() => `loaded-${++calls}`);
4761

62+
expect(loader.peek()).toBeUndefined();
4863
await expect(loader.load()).resolves.toBe("loaded-1");
64+
await expect(loader.peek()).resolves.toBe("loaded-1");
4965
loader.clear();
66+
expect(loader.peek()).toBeUndefined();
5067
await expect(loader.load()).resolves.toBe("loaded-2");
5168
});
5269
});

src/shared/lazy-promise.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
/** Manual-control promise cache for lazy runtime resources. */
22
export type LazyPromiseLoader<T> = {
33
/** Resolves the cached value, creating one load promise when needed. */
4-
load(): Promise<T>;
4+
load: () => Promise<T>;
5+
/** Returns the current cached promise without starting a load. */
6+
peek: () => Promise<T> | undefined;
57
/** Drops the cached promise so the next load starts fresh. */
6-
clear(): void;
8+
clear: () => void;
79
};
810

911
/** Options for controlling lazy promise cache behavior. */
@@ -38,16 +40,28 @@ export function createLazyPromiseLoader<T>(
3840
};
3941

4042
return {
41-
async load(): Promise<T> {
43+
load(): Promise<T> {
4244
promise ??= createPromise();
43-
return await promise;
45+
return promise;
46+
},
47+
peek(): Promise<T> | undefined {
48+
return promise;
4449
},
4550
clear(): void {
4651
promise = undefined;
4752
},
4853
};
4954
}
5055

56+
/** Creates a reusable function that resolves one cached promise at a time. */
57+
export function createLazyPromise<T>(
58+
load: () => T | Promise<T>,
59+
options?: LazyPromiseLoaderOptions,
60+
): () => Promise<T> {
61+
const loader = createLazyPromiseLoader(load, options);
62+
return () => loader.load();
63+
}
64+
5165
/** Convenience wrapper for dynamic-import-shaped loaders. */
5266
export function createLazyImportLoader<T>(
5367
load: () => Promise<T>,

src/shared/lazy-runtime.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { createLazyRuntimeModule, createLazyRuntimeSurface } from "./lazy-runtime.js";
3+
4+
describe("lazy runtime helpers", () => {
5+
it("caches imported modules", async () => {
6+
const importer = vi.fn(async () => ({ value: "module" }));
7+
const load = createLazyRuntimeModule(importer);
8+
const first = load();
9+
10+
expect(load()).toBe(first);
11+
expect(load.peek()).toBe(first);
12+
await expect(first).resolves.toEqual({ value: "module" });
13+
expect(importer).toHaveBeenCalledOnce();
14+
});
15+
16+
it("can clear imported modules", async () => {
17+
const importer = vi.fn(async () => ({ value: "module" }));
18+
const load = createLazyRuntimeModule(importer);
19+
20+
await load();
21+
load.clear();
22+
expect(load.peek()).toBeUndefined();
23+
await load();
24+
expect(importer).toHaveBeenCalledTimes(2);
25+
});
26+
27+
it("preserves cached runtime import rejections", async () => {
28+
const importer = vi.fn(async () => {
29+
throw new Error("sticky");
30+
});
31+
const load = createLazyRuntimeSurface(importer, (module) => module);
32+
33+
await expect(load()).rejects.toThrow("sticky");
34+
await expect(load()).rejects.toThrow("sticky");
35+
expect(importer).toHaveBeenCalledOnce();
36+
});
37+
});

src/shared/lazy-runtime.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,31 @@
1+
import { createLazyPromiseLoader } from "./lazy-promise.js";
2+
3+
export { createLazyPromise, createLazyPromiseLoader } from "./lazy-promise.js";
4+
export type { LazyPromiseLoader } from "./lazy-promise.js";
5+
6+
type LazyRuntimeLoader<T> = (() => Promise<T>) & {
7+
peek: () => Promise<T> | undefined;
8+
clear: () => void;
9+
};
10+
111
// Lazy runtime helpers expose dynamic imports through cached runtime surfaces.
212
export function createLazyRuntimeSurface<TModule, TSurface>(
313
importer: () => Promise<TModule>,
414
select: (module: TModule) => TSurface,
5-
): () => Promise<TSurface> {
6-
let cached: Promise<TSurface> | null = null;
7-
return () => {
8-
cached ??= importer().then(select);
9-
return cached;
10-
};
15+
): LazyRuntimeLoader<TSurface> {
16+
const loader = createLazyPromiseLoader(() => importer().then(select), {
17+
cacheRejections: true,
18+
});
19+
const load = loader.load as LazyRuntimeLoader<TSurface>;
20+
load.peek = loader.peek;
21+
load.clear = loader.clear;
22+
return load;
1123
}
1224

1325
/** Cache the raw dynamically imported runtime module behind a stable loader. */
1426
export function createLazyRuntimeModule<TModule>(
1527
importer: () => Promise<TModule>,
16-
): () => Promise<TModule> {
28+
): LazyRuntimeLoader<TModule> {
1729
return createLazyRuntimeSurface(importer, (module) => module);
1830
}
1931

0 commit comments

Comments
 (0)