Skip to content

Commit 919700f

Browse files
authored
perf(cli): keep node:http and ICU off the CLI startup path (-54% warm call) (#1681)
* perf(cli): keep node:http and ICU off the CLI startup path (-54% warm call) A warm `appstate` cost 146ms; 79ms of that was startup work no local command needs. `node:http` was value-imported by four modules inside the eager import closure of `src/cli.ts`, so every invocation initialized undici -- and, under `NODE_USE_SYSTEM_CA=1`, the macOS trust store behind it. That is ~70ms of mostly off-CPU time (a blocking trustd lookup, invisible to --cpu-prof), paid by commands that never speak HTTP: the default daemon transport is a plain `node:net` socket. Cheap daemon-only commands paid it in full; only commands slow enough to absorb it, like `snapshot`, did not. Separately, `option-schema.ts` sorted its specs with `localeCompare` at module scope. The first `localeCompare` in a process costs ~5.4ms of ICU collator initialization (every later one is ~0.002ms), so the CLI paid ICU init purely to alphabetize an internal list. A case-insensitive comparator reproduces the exact same order for the ASCII flag-key vocabulary -- verified against all 159 keys. Both HTTP modules are now loaded on demand, via `.default` so they stay the same mutable module object a static import yields (the transport tests stub `http.request` on it). Behavior is unchanged: the HTTP transport, remote daemons and artifact upload/download all still work. Interleaved A/B, alternating prebuilt dists in one loop, 5-min load avg < 10, n=30 per arm: appstate 146.4ms -> 67.6ms -53.8% session list 137.9ms -> 57.7ms -58.2% snapshot -i 195.5ms -> 188.8ms -3.4% (device-work bound) Without NODE_USE_SYSTEM_CA the appstate win is 73.9ms -> 57.9ms (-22%), so this is not purely an artifact of that setting. Measured and rejected: splitting command metadata from runtime handlers (the facet restructure deferred by #1660). registry.js is 142kB of the 643kB eager closure but only ~1.6ms of module evaluation, and ALL module compilation is ~9.5ms -- the whole refactor could not clear the 20ms bar set for attempting it. A unit test walks the eager closure of src/cli.ts and fails if any file in it value-imports node:http/node:https again; type-only imports stay allowed. * test(cli): classify startup-closure imports with the ES module record The regex-based guard only recognized `import ... from` / `export ... from` forms, so a bare side-effect `import 'node:http'` anywhere in the eager closure would reintroduce the full undici + system-CA initialization cost while the guard stayed green -- the one form that binds nothing yet still evaluates the module. Classification now comes from oxc-parser's ES module record, the parser this repo already uses for import analysis in scripts/lib/shipped-imports. A side-effect import is exactly an entry-less static import, so it falls out of the record rather than needing another regex branch; type-only imports and re-exports are identified by their own `isType` flag instead of being pattern-matched. A fixture table now pins all eleven import forms -- side-effect, default, namespace, named, value re-export, star re-export, mixed value+type, type-only default/named/re-export, and dynamic -- so the classifier's contract is asserted directly rather than only through the closure walk. Verified red end to end: a side-effect import inserted into a real closure module fails the guard and names the file. * test(cli): follow workspace subpath imports in the startup-closure guard The walk stopped at the package boundary, so a `node:http` import inside any @agent-device/* module the CLI evaluates would have reintroduced the cost invisibly -- the same blind spot the module-record rewrite closed for side-effect imports, just relocated. Workspace specifiers now resolve through each package's exports map. The anti-vacuity assertion checks the closure actually contains package files, so a resolver that silently returned null for every workspace specifier would fail rather than leave the guard green over a src-only closure. Verified red both ways: a side-effect import inserted into packages/kernel/src/errors.ts, and one in a src/ module, each fail the guard and name the offending file. * test(cli): drop a redundant cast in the workspace exports lookup * test(cli): classify a top-level dynamic import as eager, and share one HTTP loader "On demand" is a claim about SCOPE, not syntax, and the guard only knew syntax. `import('node:http')` at MODULE TOP LEVEL runs during module evaluation -- so a top-level `await import(...)`, an `import(...).then(...)`, or an immediately-invoked top-level function reintroduced the whole undici plus trust-store cost while the guard stayed green. A dynamic import is lazy only when its nearest enclosing function scope is not the module itself. The guard now walks each closure file's OXC AST carrying an `eager` flag that drops on entry to a FunctionDeclaration/FunctionExpression/ArrowFunction body, and reports `import()` calls still reached with it set. Immediately-invoked top-level functions are detected by unwrapping the callee's ParenthesizedExpression, so `(async () => { ... })()` is eager too. The one shape that still reads as lazy -- a top-level function invoked indirectly at load time -- is stated as a known limitation in the test rather than left implied. Verified red by inserting each shape into a REACHABLE closure module (daemon-client-transport.ts): top-level await, `.then(...)`, and the top-level IIFE each fail the guard and name the file. The lazy case is proven by the suite itself rather than a fixture alone -- the shared loader below lives in the closure, so its function-local `await import('node:http')` would turn the guard red if scope tracking were wrong. The three lazy-load sites had drifted into three copies of the same ternary. They now share `loadNodeHttpRequester` in src/utils/node-http.ts, next to the other node:http helpers, which is where the reason for the indirection is documented once. This also clears the Fallow complexity finding the previous push introduced. Fixture table extended to 19 cases covering both axes: static form (side-effect, default, namespace, named, value/star re-export, mixed, type-only x3) and dynamic scope (top-level x3, function/arrow/method-local, and the exact ternary the shipped loader uses).
1 parent e6b4fa2 commit 919700f

7 files changed

Lines changed: 329 additions & 17 deletions

File tree

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,271 @@
1+
import { expect, test } from 'vitest';
2+
import fs from 'node:fs';
3+
import path from 'node:path';
4+
import { parseSync } from 'oxc-parser';
5+
6+
/**
7+
* Every CLI invocation eagerly evaluates the static import closure of
8+
* `src/cli.ts`. Importing `node:http` (or `node:https`) for a VALUE inside that
9+
* closure initializes undici and, under `NODE_USE_SYSTEM_CA=1`, the platform
10+
* trust store as well -- ~79ms added to every warm command on macOS, including
11+
* ones that never speak HTTP (the default daemon transport is a `node:net`
12+
* socket). The HTTP daemon transport, remote-artifact upload and download all
13+
* load these modules on demand instead.
14+
*
15+
* "On demand" is a claim about SCOPE, not about syntax, which is why this reads
16+
* the AST rather than matching import forms. Two shapes evaluate the module
17+
* during module evaluation while looking lazy or looking like nothing at all:
18+
* a bare side-effect `import 'node:http'` (binds no names, still evaluates),
19+
* and a dynamic `import('node:http')` sitting at module top level rather than
20+
* inside a function -- including `.then(...)` and an immediately-invoked
21+
* top-level function. A dynamic import is lazy only when its nearest enclosing
22+
* function scope is not the module itself. Type-only imports and re-exports are
23+
* erased at build and stay allowed.
24+
*
25+
* Known limitation: a top-level function that is invoked indirectly at load
26+
* time (stored, then called by another top-level statement) reads as lazy here.
27+
* Direct top-level invocation -- `(() => { ... })()` -- is detected.
28+
*/
29+
30+
const srcRoot = path.resolve(import.meta.dirname, '..');
31+
const LAZY_HTTP_MODULES = new Set(['node:http', 'node:https']);
32+
const FUNCTION_NODES = new Set([
33+
'FunctionDeclaration',
34+
'FunctionExpression',
35+
'ArrowFunctionExpression',
36+
]);
37+
const WORKSPACE_SPECIFIER = /^(@agent-device\/[^/]+)(\/.*)?$/;
38+
39+
type AstNode = { type?: string; [key: string]: unknown };
40+
41+
type ParsedModuleRecord = ReturnType<typeof parseSync>['module'];
42+
43+
/** Static specifiers this file causes to be EVALUATED (not type-only). */
44+
function staticEvaluatedRefs(module: ParsedModuleRecord): string[] {
45+
const refs: string[] = [];
46+
for (const staticImport of module.staticImports) {
47+
// No entries at all is a side-effect import (`import 'x'`), which always
48+
// evaluates. Otherwise it evaluates unless every binding is type-only.
49+
const evaluates =
50+
staticImport.entries.length === 0 || staticImport.entries.some((entry) => !entry.isType);
51+
if (evaluates) refs.push(staticImport.moduleRequest.value);
52+
}
53+
for (const staticExport of module.staticExports) {
54+
for (const entry of staticExport.entries) {
55+
if (entry.moduleRequest && !entry.isType) refs.push(entry.moduleRequest.value);
56+
}
57+
}
58+
return refs;
59+
}
60+
61+
function unwrapParentheses(node: unknown): AstNode | null {
62+
let current = node as AstNode | null;
63+
while (current?.type === 'ParenthesizedExpression')
64+
current = current.expression as AstNode | null;
65+
return current;
66+
}
67+
68+
/** The body of a function invoked right where it is defined, if this is that call. */
69+
function immediatelyInvokedBody(node: AstNode): unknown {
70+
if (node.type !== 'CallExpression') return null;
71+
const callee = unwrapParentheses(node.callee);
72+
return callee && FUNCTION_NODES.has(String(callee.type)) ? callee.body : null;
73+
}
74+
75+
function dynamicImportSpecifier(node: AstNode): string | null {
76+
if (node.type !== 'ImportExpression') return null;
77+
const source = node.source as { type?: string; value?: unknown } | undefined;
78+
return source?.type === 'Literal' && typeof source.value === 'string' ? source.value : null;
79+
}
80+
81+
function recordDynamicImport(record: AstNode, eager: boolean, found: string[]): void {
82+
if (!eager) return;
83+
const specifier = dynamicImportSpecifier(record);
84+
if (specifier !== null) found.push(specifier);
85+
}
86+
87+
/** Descends into a node's children, dropping `eager` on the way into a function body. */
88+
function visitChildren(record: AstNode, eager: boolean, found: string[]): void {
89+
const childEager = eager && !FUNCTION_NODES.has(String(record.type));
90+
for (const [key, value] of Object.entries(record)) {
91+
if (key !== 'type') collectEagerDynamicImports(value, childEager, found);
92+
}
93+
}
94+
95+
/** Walks the AST, collecting only `import()` calls reached without entering a function. */
96+
function collectEagerDynamicImports(node: unknown, eager: boolean, found: string[]): void {
97+
if (Array.isArray(node)) {
98+
for (const child of node) collectEagerDynamicImports(child, eager, found);
99+
return;
100+
}
101+
if (!node || typeof node !== 'object') return;
102+
const record = node as AstNode;
103+
recordDynamicImport(record, eager, found);
104+
// An immediately-invoked function runs now, so its body inherits `eager`.
105+
const invokedBody = immediatelyInvokedBody(record);
106+
if (invokedBody) collectEagerDynamicImports(invokedBody, eager, found);
107+
visitChildren(record, eager, found);
108+
}
109+
110+
/** Every specifier this file evaluates at load time, static or dynamic. */
111+
function eagerlyEvaluatedModules(fileName: string, source: string): string[] {
112+
const parsed = parseSync(fileName, source);
113+
const dynamic: string[] = [];
114+
collectEagerDynamicImports(parsed.program, true, dynamic);
115+
return [...new Set([...staticEvaluatedRefs(parsed.module), ...dynamic])];
116+
}
117+
118+
function resolveRelative(fromFile: string, specifier: string): string | null {
119+
const candidate = path.resolve(path.dirname(fromFile), specifier);
120+
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
121+
for (const suffix of ['.ts', '.tsx', '/index.ts']) {
122+
if (fs.existsSync(`${candidate}${suffix}`)) return `${candidate}${suffix}`;
123+
}
124+
return null;
125+
}
126+
127+
/** `@agent-device/<pkg>` -> that package's directory, keyed by its declared name. */
128+
function readWorkspacePackageDirs(): Map<string, string> {
129+
const packagesRoot = path.resolve(srcRoot, '..', 'packages');
130+
const dirs = new Map<string, string>();
131+
for (const entry of fs.readdirSync(packagesRoot)) {
132+
const manifestPath = path.join(packagesRoot, entry, 'package.json');
133+
if (!fs.existsSync(manifestPath)) continue;
134+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as { name?: string };
135+
if (manifest.name) dirs.set(manifest.name, path.join(packagesRoot, entry));
136+
}
137+
return dirs;
138+
}
139+
140+
type ExportTarget = { default?: string; types?: string } | string;
141+
142+
function readExportTarget(packageDir: string, subpath: string): string | undefined {
143+
const manifest = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')) as {
144+
exports?: Record<string, ExportTarget>;
145+
};
146+
const target = manifest.exports?.[subpath];
147+
if (typeof target === 'string') return target;
148+
return target?.default ?? target?.types;
149+
}
150+
151+
/**
152+
* Workspace subpath imports are followed too: a package the CLI evaluates can
153+
* pull `node:http` in just as effectively as a file under src/, and stopping the
154+
* walk at the package boundary would be the same blind spot in a new place.
155+
*/
156+
function resolveWorkspace(specifier: string, packageDirs: Map<string, string>): string | null {
157+
const match = WORKSPACE_SPECIFIER.exec(specifier);
158+
const packageName = match?.[1];
159+
const packageDir = packageName ? packageDirs.get(packageName) : undefined;
160+
if (!packageDir) return null;
161+
const target = readExportTarget(packageDir, `.${match?.[2] ?? ''}`);
162+
if (!target) return null;
163+
const resolved = path.resolve(packageDir, target);
164+
return fs.existsSync(resolved) ? resolved : null;
165+
}
166+
167+
/** Every repo file evaluated as a consequence of importing `src/cli.ts`. */
168+
function eagerClosureOfCli(): string[] {
169+
const packageDirs = readWorkspacePackageDirs();
170+
const queue = [path.join(srcRoot, 'cli.ts')];
171+
const visited = new Set<string>();
172+
while (queue.length > 0) {
173+
const current = queue.pop();
174+
if (!current || visited.has(current)) continue;
175+
visited.add(current);
176+
for (const specifier of eagerlyEvaluatedModules(current, fs.readFileSync(current, 'utf8'))) {
177+
const resolved = specifier.startsWith('.')
178+
? resolveRelative(current, specifier)
179+
: resolveWorkspace(specifier, packageDirs);
180+
if (resolved) queue.push(resolved);
181+
}
182+
}
183+
return [...visited];
184+
}
185+
186+
test.for([
187+
// --- static forms ---
188+
{ form: 'side-effect import', code: `import 'node:http';`, eager: true },
189+
{ form: 'default value import', code: `import http from 'node:http';`, eager: true },
190+
{ form: 'namespace import', code: `import * as http from 'node:http';`, eager: true },
191+
{ form: 'named value import', code: `import { request } from 'node:http';`, eager: true },
192+
{ form: 'value re-export', code: `export { request } from 'node:http';`, eager: true },
193+
{ form: 'star re-export', code: `export * from 'node:http';`, eager: true },
194+
{
195+
form: 'mixed value + type import',
196+
code: `import http, { type IncomingMessage } from 'node:http';`,
197+
eager: true,
198+
},
199+
{ form: 'type-only default import', code: `import type http from 'node:http';`, eager: false },
200+
{
201+
form: 'type-only named import',
202+
code: `import { type IncomingMessage } from 'node:http';`,
203+
eager: false,
204+
},
205+
{
206+
form: 'type-only re-export',
207+
code: `export type { IncomingMessage } from 'node:http';`,
208+
eager: false,
209+
},
210+
// --- dynamic import: scope decides, not syntax ---
211+
{ form: 'top-level await import', code: `const m = await import('node:http');`, eager: true },
212+
{ form: 'top-level import().then', code: `import('node:http').then((m) => m);`, eager: true },
213+
{
214+
form: 'top-level immediately-invoked arrow',
215+
code: `(async () => { await import('node:http'); })();`,
216+
eager: true,
217+
},
218+
{
219+
form: 'function-declaration-local import',
220+
code: `async function load() { return await import('node:http'); }`,
221+
eager: false,
222+
},
223+
{
224+
form: 'arrow-local import',
225+
code: `const load = async () => await import('node:http');`,
226+
eager: false,
227+
},
228+
{
229+
form: 'method-local import',
230+
code: `class K { async load() { await import('node:http'); } }`,
231+
eager: false,
232+
},
233+
{
234+
form: 'ternary inside a function (the shipped lazy-load shape)',
235+
code: `async function load(s: boolean) {
236+
return s ? (await import('node:https')).default : (await import('node:http')).default;
237+
}`,
238+
eager: false,
239+
},
240+
])('$form is eager=$eager', ({ code, eager }) => {
241+
const refs = eagerlyEvaluatedModules('fixture.ts', code);
242+
expect(refs.includes('node:http') || refs.includes('node:https')).toBe(eager);
243+
});
244+
245+
test('the CLI startup import closure never evaluates node:http or node:https', () => {
246+
const offenders: string[] = [];
247+
for (const file of eagerClosureOfCli()) {
248+
for (const specifier of eagerlyEvaluatedModules(file, fs.readFileSync(file, 'utf8'))) {
249+
if (LAZY_HTTP_MODULES.has(specifier)) {
250+
offenders.push(`${path.relative(srcRoot, file)} -> ${specifier}`);
251+
}
252+
}
253+
}
254+
255+
expect(
256+
offenders,
257+
'Load node:http / node:https on demand instead: evaluating either one here costs every warm ' +
258+
'CLI command ~79ms of undici + system-CA initialization.',
259+
).toEqual([]);
260+
});
261+
262+
test('the CLI startup import closure is reachable and crosses the package boundary', () => {
263+
// Guards the test above from silently passing because the walk found nothing:
264+
// a resolver that returned null for everything would leave both the src side
265+
// and the workspace side of the closure empty while the guard stayed green.
266+
const closure = eagerClosureOfCli();
267+
expect(closure.length).toBeGreaterThan(50);
268+
expect(
269+
closure.filter((file) => file.includes(`${path.sep}packages${path.sep}`)).length,
270+
).toBeGreaterThan(0);
271+
});

src/cli-schema/option-schema.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,20 @@ function buildOptionSpecs(): OptionSpec[] {
9191
return supported.has(command);
9292
},
9393
}))
94-
.sort((left, right) => left.key.localeCompare(right.key));
94+
.sort((left, right) => compareOptionKeys(left.key, right.key));
95+
}
96+
97+
/**
98+
* Case-insensitive-then-codepoint ordering, matching what `localeCompare`
99+
* produces for the ASCII flag-key vocabulary. This runs at module load on
100+
* every CLI invocation, and the FIRST `localeCompare` in a process pays ~5ms
101+
* of ICU collator initialization — a cost the CLI otherwise never incurs.
102+
*/
103+
function compareOptionKeys(left: string, right: string): number {
104+
const leftLower = left.toLowerCase();
105+
const rightLower = right.toLowerCase();
106+
if (leftLower !== rightLower) return leftLower < rightLower ? -1 : 1;
107+
return left < right ? -1 : left > right ? 1 : 0;
95108
}
96109

97110
function primaryFlagDefinition(spec: OptionSpec): FlagDefinition {

src/daemon/client/daemon-client-progress.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { RequestProgressEvent, RequestProgressSink } from '@agent-device/contracts/progress';
2-
import http from 'node:http';
2+
// Type-only: importing `node:http` for a value eagerly initializes undici
3+
// (~9ms in a fresh process), which the default socket transport never needs.
4+
import type http from 'node:http';
35
import type { Socket } from 'node:net';
46
import { AppError } from '@agent-device/kernel/errors';
57
import type { DaemonRequest, DaemonResponse } from '../types.ts';

src/daemon/client/daemon-client-transport.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import type { RequestProgressSink } from '@agent-device/contracts/progress';
22
import net from 'node:net';
3-
import http from 'node:http';
4-
import https from 'node:https';
53
import { AppError } from '@agent-device/kernel/errors';
6-
import { readNodeHttpResponseBody } from '../../utils/node-http.ts';
4+
import { loadNodeHttpRequester, readNodeHttpResponseBody } from '../../utils/node-http.ts';
75
import type { DaemonRequest, DaemonResponse } from '../types.ts';
86
import { emitDiagnostic } from '../../utils/diagnostics.ts';
97
import type { DaemonPaths, DaemonTransportPreference } from '../config.ts';
@@ -102,19 +100,19 @@ export async function readRemoteDaemonHealth(info: DaemonInfo): Promise<RemoteDa
102100
return health;
103101
}
104102

105-
function readDaemonHttpHealth(info: DaemonInfo): Promise<RemoteDaemonHealth> {
103+
async function readDaemonHttpHealth(info: DaemonInfo): Promise<RemoteDaemonHealth> {
106104
const endpoint = info.baseUrl
107105
? buildDaemonHttpUrl(info.baseUrl, 'health')
108106
: info.httpPort
109107
? `http://127.0.0.1:${info.httpPort}/health`
110108
: null;
111-
if (!endpoint) return Promise.resolve({ reachable: false });
109+
if (!endpoint) return { reachable: false };
112110
const url = new URL(endpoint);
113-
const transport = url.protocol === 'https:' ? https : http;
111+
const transport = await loadNodeHttpRequester(url.protocol);
114112
const timeoutMs = info.baseUrl
115113
? REMOTE_DAEMON_HEALTHCHECK_TIMEOUT_MS
116114
: LOCAL_DAEMON_HEALTHCHECK_TIMEOUT_MS;
117-
return new Promise((resolve) => {
115+
return await new Promise((resolve) => {
118116
const headers = info.baseUrl ? buildDaemonHttpAuthHeaders(info.token) : {};
119117
const req = transport.request(
120118
{
@@ -380,9 +378,9 @@ async function sendHttpRequest(
380378
if (info.baseUrl) {
381379
Object.assign(headers, buildDaemonHttpAuthHeaders(info.token));
382380
}
381+
const transport = await loadNodeHttpRequester(rpcUrl.protocol);
383382

384383
return await new Promise((resolve, reject) => {
385-
const transport = rpcUrl.protocol === 'https:' ? https : http;
386384
const request = transport.request(
387385
{
388386
protocol: rpcUrl.protocol,

src/remote/daemon-artifacts.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import fs from 'node:fs';
2-
import http from 'node:http';
3-
import https from 'node:https';
42
import path from 'node:path';
53
import { pipeline } from 'node:stream/promises';
64
import { AppError } from '@agent-device/kernel/errors';
5+
import { loadNodeHttpRequester } from '../utils/node-http.ts';
76
import type { DaemonArtifact, DaemonRequest, DaemonResponse } from '../daemon/types.ts';
87
import {
98
buildDaemonHttpAuthHeaders,
@@ -349,7 +348,9 @@ type DownloadRemoteArtifactParams = {
349348

350349
export async function downloadRemoteArtifact(params: DownloadRemoteArtifactParams): Promise<void> {
351350
const artifactUrl = new URL(buildDaemonArtifactUrl(params.baseUrl, params.artifactId));
352-
const transport = artifactUrl.protocol === 'https:' ? https : http;
351+
// `prepareRemoteRequestArtifacts` runs on every CLI request, but only a
352+
// remote daemon ever downloads an artifact, so the HTTP stack loads here.
353+
const transport = await loadNodeHttpRequester(artifactUrl.protocol);
353354
await fs.promises.mkdir(path.dirname(params.destinationPath), { recursive: true });
354355
await new Promise<void>((resolve, reject) => {
355356
let settled = false;

src/remote/upload-stream.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import fs from 'node:fs';
2-
import http, { type IncomingHttpHeaders } from 'node:http';
3-
import https from 'node:https';
2+
import type { IncomingHttpHeaders } from 'node:http';
43
import path from 'node:path';
54
import { pipeline } from 'node:stream/promises';
65
import { AppError } from '@agent-device/kernel/errors';
7-
import { readNodeHttpResponseBody } from '../utils/node-http.ts';
6+
import { loadNodeHttpRequester, readNodeHttpResponseBody } from '../utils/node-http.ts';
87
import {
98
createUploadProgressTransform,
109
type UploadProgressSink,
@@ -65,7 +64,10 @@ async function streamFileToHttpRequestAttempt(options: {
6564
startOffset: number;
6665
progress?: UploadStreamProgressOptions;
6766
}): Promise<UploadStreamResponse> {
68-
const transport = options.url.protocol === 'https:' ? https : http;
67+
// This module sits in the CLI's eager import closure via the remote-artifact
68+
// upload client, but only a remote daemon ever uploads, so the HTTP stack
69+
// loads here rather than at import time.
70+
const transport = await loadNodeHttpRequester(options.url.protocol);
6971
const payloadSize = fs.statSync(options.payloadPath).size;
7072
const headers = buildUploadRequestHeaders(options.headers, options.startOffset, payloadSize);
7173
emitUploadAttemptStarted(options.progress, options.startOffset, payloadSize);

0 commit comments

Comments
 (0)