Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ import type {
import { WorkspaceManager } from './workspace-manager.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const executor = new UndiciExecutor();
const executor = new UndiciExecutor({
autoHeaderEnv: {
version: app.getVersion(),
platform: `${process.platform} ${process.arch}`,
},
});
const workspaceManager = new WorkspaceManager();
const oauth2Client = new OAuth2Client();
let historyStore: HistoryStore | null = null;
Expand Down
132 changes: 132 additions & 0 deletions apps/desktop/src/renderer/src/components/AutoHeadersPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { useMemo, useState } from 'react';
import type { BuilderState } from '../store.js';

interface PreviewRow {
key: string;
value: string;
readonly?: boolean;
}

function contentTypeForBuilder(builder: BuilderState): string | null {
if (builder.bodyType === 'none' || !builder.body.trim()) return null;
if (builder.bodyType === 'json') return 'application/json';
if (builder.bodyType === 'text') return 'text/plain';
return null;
}

function previewAutoHeaders(builder: BuilderState): PreviewRow[] {
const rows: PreviewRow[] = [
{ key: 'User-Agent', value: 'Scrapeman/<version> (<platform>)' },
{ key: 'Accept', value: '*/*' },
{ key: 'Accept-Encoding', value: 'gzip, deflate, br' },
{ key: 'Cache-Control', value: 'no-cache' },
{ key: 'Connection', value: 'keep-alive' },
{ key: 'X-Scrapeman-Token', value: '<uuid per request>' },
];
const ct = contentTypeForBuilder(builder);
if (ct) rows.push({ key: 'Content-Type', value: ct });
rows.push({ key: 'Host', value: '<from URL>', readonly: true });
rows.push({ key: 'Content-Length', value: '<computed>', readonly: true });
return rows;
}

export function AutoHeadersPanel({
builder,
onChange,
}: {
builder: BuilderState;
onChange: (disabled: string[]) => void;
}): JSX.Element {
const [open, setOpen] = useState(false);
const rows = useMemo(() => previewAutoHeaders(builder), [builder]);
const disabled = useMemo(
() => new Set(builder.disabledAutoHeaders.map((k) => k.toLowerCase())),
[builder.disabledAutoHeaders],
);
const userKeys = useMemo(
() =>
new Set(
builder.headers
.filter((h) => h.enabled && h.key.trim())
.map((h) => h.key.trim().toLowerCase()),
),
[builder.headers],
);

const toggle = (key: string): void => {
const lower = key.toLowerCase();
const next = disabled.has(lower)
? builder.disabledAutoHeaders.filter((k) => k.toLowerCase() !== lower)
: [...builder.disabledAutoHeaders, key];
onChange(next);
};

return (
<div className="border-b border-line bg-bg-subtle/40">
<button
onClick={() => setOpen(!open)}
className="flex w-full items-center justify-between px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-ink-4 hover:text-ink-2"
>
<span>Auto Headers ({rows.length})</span>
<span>{open ? 'Hide' : 'Show'}</span>
</button>
{open && (
<div className="flex flex-col border-t border-line-subtle">
{rows.map((row) => {
const lower = row.key.toLowerCase();
const isDisabled = disabled.has(lower);
const overridden = userKeys.has(lower);
const isReadonly = row.readonly === true;
return (
<div
key={row.key}
className="grid grid-cols-[32px_1fr_1.5fr_auto] items-center border-b border-line-subtle px-3 py-1"
>
<div className="flex items-center justify-center">
{isReadonly ? (
<span className="text-ink-4">--</span>
) : (
<input
type="checkbox"
checked={!isDisabled && !overridden}
disabled={overridden}
onChange={() => toggle(row.key)}
className="h-3.5 w-3.5 cursor-pointer accent-accent disabled:cursor-not-allowed"
/>
)}
</div>
<div
className={`py-1 font-mono text-xs ${
isDisabled || overridden ? 'text-ink-4 line-through' : 'text-ink-2'
}`}
>
{row.key}
</div>
<div className="truncate py-1 font-mono text-xs text-ink-3">
{row.value}
</div>
<div className="pl-2">
{overridden && (
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-accent">
overridden
</span>
)}
{!overridden && isReadonly && (
<span className="rounded bg-bg-hover px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-ink-4">
readonly
</span>
)}
{!overridden && !isReadonly && isDisabled && (
<span className="rounded bg-bg-hover px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-ink-4">
disabled
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}
20 changes: 14 additions & 6 deletions apps/desktop/src/renderer/src/components/RequestBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { useAppStore } from '../store.js';
import { MethodPicker } from './MethodPicker.js';
import { HeadersEditor } from './HeadersEditor.js';
import { AutoHeadersPanel } from './AutoHeadersPanel.js';
import { ParamsEditor } from './ParamsEditor.js';
import { SettingsTab as SettingsTabPanel } from './SettingsTab.js';
import { AuthTab } from './AuthTab.js';
Expand All @@ -24,6 +25,7 @@ export function RequestBuilder(): JSX.Element {
const addHeader = useAppStore((s) => s.addHeader);
const updateHeader = useAppStore((s) => s.updateHeader);
const removeHeader = useAppStore((s) => s.removeHeader);
const setDisabledAutoHeaders = useAppStore((s) => s.setDisabledAutoHeaders);
const addParam = useAppStore((s) => s.addParam);
const updateParam = useAppStore((s) => s.updateParam);
const removeParam = useAppStore((s) => s.removeParam);
Expand Down Expand Up @@ -215,12 +217,18 @@ export function RequestBuilder(): JSX.Element {
/>
)}
{tab === 'headers' && (
<HeadersEditor
rows={builder.headers}
onAdd={addHeader}
onUpdate={updateHeader}
onRemove={removeHeader}
/>
<div className="flex flex-col">
<AutoHeadersPanel
builder={builder}
onChange={setDisabledAutoHeaders}
/>
<HeadersEditor
rows={builder.headers}
onAdd={addHeader}
onUpdate={updateHeader}
onRemove={removeHeader}
/>
</div>
)}
{tab === 'auth' && <AuthTab />}
{tab === 'settings' && <SettingsTabPanel />}
Expand Down
16 changes: 16 additions & 0 deletions apps/desktop/src/renderer/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface BuilderState {
bodyType: 'none' | 'json' | 'text';
auth: AuthConfig;
settings: SettingsState;
disabledAutoHeaders: string[];
}

export interface ExecutionState {
Expand Down Expand Up @@ -143,6 +144,7 @@ interface AppState {

updateSettings: (patch: Partial<SettingsState>) => void;
setAuth: (auth: AuthConfig) => void;
setDisabledAutoHeaders: (keys: string[]) => void;

send: () => Promise<void>;
cancelSend: () => void;
Expand Down Expand Up @@ -317,6 +319,7 @@ function builderFromRequest(request: ScrapemanRequest): BuilderState {
bodyType,
auth: request.auth ?? { type: 'none' },
settings,
disabledAutoHeaders: request.disabledAutoHeaders ?? [],
};
}

Expand Down Expand Up @@ -371,6 +374,9 @@ function buildRequest(
options.httpVersion = s.httpVersion;
}
if (Object.keys(options).length > 0) request.options = options;
if (builder.disabledAutoHeaders.length > 0) {
request.disabledAutoHeaders = [...builder.disabledAutoHeaders];
}

return request;
}
Expand All @@ -391,6 +397,7 @@ function emptyDraftTab(): Tab {
bodyType: 'none',
auth: { type: 'none' },
settings: freshSettings(),
disabledAutoHeaders: [],
},
dirty: false,
execution: freshExecution(),
Expand Down Expand Up @@ -722,6 +729,14 @@ export const useAppStore = create<AppState>((set, get) => {
}));
},

setDisabledAutoHeaders: (keys) => {
mutateActive((tab) => ({
...tab,
builder: { ...tab.builder, disabledAutoHeaders: keys },
dirty: true,
}));
},

send: async () => {
const { activeTabId, tabs } = get();
if (!activeTabId) return;
Expand Down Expand Up @@ -981,6 +996,7 @@ export const useAppStore = create<AppState>((set, get) => {
bodyType,
auth: { type: 'none' },
settings: freshSettings(),
disabledAutoHeaders: [],
},
dirty: false,
execution,
Expand Down
21 changes: 10 additions & 11 deletions packages/http-core/src/adapters/undici-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
} from '@scrapeman/shared-types';
import type { RequestExecutor } from '../executor.js';
import { ExecutorError } from '../errors.js';
import { buildAutoHeaders, mergeHeaders } from '../auto-headers.js';

const DEFAULT_MAX_REDIRECTS = 10;
const DEFAULT_TOTAL_TIMEOUT_MS = 120_000;
Expand All @@ -22,21 +23,29 @@ const DEFAULT_MAX_RESPONSE_BYTES = 200 * 1024 * 1024; // 200 MB

export interface UndiciExecutorOptions {
maxResponseBytes?: number;
autoHeaderEnv?: { version: string; platform: string };
}

export class UndiciExecutor implements RequestExecutor {
private readonly maxResponseBytes: number;
private readonly autoHeaderEnv: { version: string; platform: string };

constructor(options: UndiciExecutorOptions = {}) {
this.maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
this.autoHeaderEnv = options.autoHeaderEnv ?? {
version: '0.0.0',
platform: `${process.platform} ${process.arch}`,
};
}

async execute(
request: ScrapemanRequest,
options: { signal?: AbortSignal } = {},
): Promise<ExecutedResponse> {
const url = buildUrl(request);
const headers = buildHeaders(request);
const auto = buildAutoHeaders(request, this.autoHeaderEnv);
const disabled = new Set(request.disabledAutoHeaders ?? []);
const headers = mergeHeaders(auto, request.headers, disabled);
const body = buildBody(request.body);
const totalTimeout = request.options?.timeout?.total ?? DEFAULT_TOTAL_TIMEOUT_MS;
const followRedirects = request.options?.redirect?.follow ?? true;
Expand Down Expand Up @@ -166,16 +175,6 @@ function parseProxyScheme(url: string): string {
return match?.[1]?.toLowerCase() ?? 'http';
}

function buildHeaders(request: ScrapemanRequest): Record<string, string> {
const headers: Record<string, string> = {};
if (request.headers) {
for (const [key, value] of Object.entries(request.headers)) {
headers[key] = value;
}
}
return headers;
}

function buildBody(body: BodyConfig | undefined): string | Uint8Array | undefined {
if (!body || body.type === 'none') return undefined;
if (
Expand Down
71 changes: 71 additions & 0 deletions packages/http-core/src/auto-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { randomUUID } from 'node:crypto';
import type { BodyConfig, ScrapemanRequest } from '@scrapeman/shared-types';

export interface AutoHeader {
key: string;
value: string;
readonly?: boolean;
}

export interface AutoHeaderEnv {
version: string;
platform: string;
}

export function contentTypeForBody(body: BodyConfig | undefined): string | null {
if (!body || body.type === 'none') return null;
if (body.type === 'json') return 'application/json';
if (body.type === 'xml') return 'application/xml';
if (body.type === 'html') return 'text/html';
if (body.type === 'javascript') return 'application/javascript';
if (body.type === 'text') return 'text/plain';
if (body.type === 'formUrlEncoded') return 'application/x-www-form-urlencoded';
if (body.type === 'binary') return 'application/octet-stream';
// multipart: undici sets the boundary — we stay out of it.
return null;
}

export function buildAutoHeaders(
request: ScrapemanRequest,
env: AutoHeaderEnv,
): AutoHeader[] {
const headers: AutoHeader[] = [
{ key: 'User-Agent', value: `Scrapeman/${env.version} (${env.platform})` },
{ key: 'Accept', value: '*/*' },
{ key: 'Accept-Encoding', value: 'gzip, deflate, br' },
{ key: 'Cache-Control', value: 'no-cache' },
{ key: 'Connection', value: 'keep-alive' },
{ key: 'X-Scrapeman-Token', value: randomUUID() },
];
const ct = contentTypeForBody(request.body);
if (ct) headers.push({ key: 'Content-Type', value: ct });
return headers;
}

export function mergeHeaders(
auto: AutoHeader[],
user: Record<string, string> | undefined,
disabled: Set<string>,
): Record<string, string> {
const disabledLower = new Set(
Array.from(disabled, (k) => k.toLowerCase()),
);
const userLowerKeys = new Set<string>();
if (user) {
for (const key of Object.keys(user)) userLowerKeys.add(key.toLowerCase());
}

const result: Record<string, string> = {};
for (const h of auto) {
const lower = h.key.toLowerCase();
if (disabledLower.has(lower)) continue;
if (userLowerKeys.has(lower)) continue; // user wins
result[h.key] = h.value;
}
if (user) {
for (const [key, value] of Object.entries(user)) {
result[key] = value;
}
}
return result;
}
1 change: 1 addition & 0 deletions packages/http-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ export * from './auth/index.js';
export * from './scrapeDo/index.js';
export * from './cookies/index.js';
export * from './load/index.js';
export * from './auto-headers.js';

export type { ScrapemanRequest, ExecutedResponse };
Loading
Loading