Skip to content

Commit 235dd65

Browse files
authored
⚡ Skip image type detection when SDK provides explicit type (#174)
## Summary - Client SDK now sends `type` field (`'base64'` or `'file-path'`) with screenshot requests - Server uses explicit type when provided, skipping O(n) detection entirely - Fallback detection optimized with length check (strings > 2KB cannot be file paths) - Fixed `looksLikeFilePath` to not false-positive on JPEG base64 (which starts with `/9j/`) ## Problem For large full-page screenshots (multi-megabyte base64 strings), the `detectImageInputType` function was running expensive O(n) regex validation on every screenshot. This caused noticeable slowdowns, especially for content-heavy pages like blog indexes and changelogs. ## Solution 1. **Explicit type from SDK (zero cost)**: The SDK already knows whether it's sending base64 or a file path, so it now tells the server explicitly 2. **Optimized fallback**: For backwards compatibility with old clients, detection now bails early on large strings (file paths are always < 2KB) ## Test plan - [x] All existing tests pass - [x] Verified detection still works correctly for file paths and base64 - [x] Backwards compatible - old clients without `type` field still work
1 parent 8879850 commit 235dd65

File tree

9 files changed

+429
-37
lines changed

9 files changed

+429
-37
lines changed

src/client/index.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,17 +195,17 @@ function createSimpleClient(serverUrl) {
195195
try {
196196
// If it's a string, assume it's a file path and send directly
197197
// Otherwise it's a Buffer, so convert to base64
198-
const image =
199-
typeof imageBuffer === 'string'
200-
? imageBuffer
201-
: imageBuffer.toString('base64');
198+
let isFilePath = typeof imageBuffer === 'string';
199+
let image = isFilePath ? imageBuffer : imageBuffer.toString('base64');
200+
let type = isFilePath ? 'file-path' : 'base64';
202201

203202
const { status, json } = await httpPost(
204203
`${serverUrl}/screenshot`,
205204
{
206205
buildId: getBuildId(),
207206
name,
208207
image,
208+
type,
209209
properties: options,
210210
fullPage: options.fullPage || false,
211211
},

src/server/handlers/api-handler.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,13 @@ export const createApiHandler = (
4949
let screenshotCount = 0;
5050
let uploadPromises = [];
5151

52-
const handleScreenshot = async (buildId, name, image, properties = {}) => {
52+
const handleScreenshot = async (
53+
buildId,
54+
name,
55+
image,
56+
properties = {},
57+
type
58+
) => {
5359
if (vizzlyDisabled) {
5460
output.debug('upload', `${name} (disabled)`);
5561
return {
@@ -75,8 +81,12 @@ export const createApiHandler = (
7581
}
7682

7783
// Support both base64 encoded images and file paths
84+
// Use explicit type from client if provided (fast path), otherwise detect (slow path)
85+
// Only accept valid type values to prevent invalid types from bypassing detection
7886
let imageBuffer;
79-
const inputType = detectImageInputType(image);
87+
let validTypes = ['base64', 'file-path'];
88+
const inputType =
89+
type && validTypes.includes(type) ? type : detectImageInputType(image);
8090

8191
if (inputType === 'file-path') {
8292
// It's a file path - resolve and read the file

src/server/handlers/tdd-handler.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,13 @@ export const createTddHandler = (
325325
}
326326
};
327327

328-
const handleScreenshot = async (_buildId, name, image, properties = {}) => {
328+
const handleScreenshot = async (
329+
_buildId,
330+
name,
331+
image,
332+
properties = {},
333+
type
334+
) => {
329335
// Validate and sanitize screenshot name
330336
let sanitizedName;
331337
try {
@@ -364,8 +370,12 @@ export const createTddHandler = (
364370

365371
// Support both base64 encoded images and file paths
366372
// Vitest browser mode returns file paths, so we need to handle both
373+
// Use explicit type from client if provided (fast path), otherwise detect (slow path)
374+
// Only accept valid type values to prevent invalid types from bypassing detection
367375
let imageBuffer;
368-
const inputType = detectImageInputType(image);
376+
let validTypes = ['base64', 'file-path'];
377+
const inputType =
378+
type && validTypes.includes(type) ? type : detectImageInputType(image);
369379

370380
if (inputType === 'file-path') {
371381
// It's a file path - resolve and read the file

src/server/routers/screenshot.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export function createScreenshotRouter({ screenshotHandler, defaultBuildId }) {
2424
if (pathname === '/screenshot') {
2525
try {
2626
const body = await parseJsonBody(req);
27-
const { buildId, name, properties, image } = body;
27+
const { buildId, name, properties, image, type } = body;
2828

2929
if (!name || !image) {
3030
sendError(res, 400, 'name and image are required');
@@ -38,7 +38,8 @@ export function createScreenshotRouter({ screenshotHandler, defaultBuildId }) {
3838
effectiveBuildId,
3939
name,
4040
image,
41-
properties
41+
properties,
42+
type
4243
);
4344

4445
sendJson(res, result.statusCode, result.body);

src/utils/image-input-detector.js

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -82,42 +82,46 @@ export function looksLikeFilePath(str) {
8282
return false;
8383
}
8484

85-
// 0. Explicitly reject data URIs first (they contain : and / which would match path patterns)
85+
// 0. Length check - file paths are short, base64 screenshots are huge
86+
// Even the longest realistic file path is < 500 chars
87+
// This makes detection O(1) for large base64 strings
88+
// Use same threshold (1000) as detectImageInputType for consistency
89+
if (str.length > 1000) {
90+
return false;
91+
}
92+
93+
// 1. Explicitly reject data URIs (they contain : and / which would match path patterns)
8694
if (str.startsWith('data:')) {
8795
return false;
8896
}
8997

90-
// 1. Check for file:// URI scheme
98+
// 2. Check for file:// URI scheme
9199
if (str.startsWith('file://')) {
92100
return true;
93101
}
94102

95-
// 2. Check for absolute paths (Unix or Windows)
96-
// Unix: starts with /
97-
// Windows: starts with drive letter like C:\ or C:/
98-
if (str.startsWith('/') || /^[A-Za-z]:[/\\]/.test(str)) {
103+
// 3. Windows absolute paths (C:\ or C:/) - base64 never starts with drive letter
104+
if (/^[A-Za-z]:[/\\]/.test(str)) {
99105
return true;
100106
}
101107

102-
// 3. Check for relative path indicators
103-
// ./ or ../ or .\ or ..\
108+
// 4. Relative path indicators (./ or ../) - base64 never starts with dot
104109
if (/^\.\.?[/\\]/.test(str)) {
105110
return true;
106111
}
107112

108-
// 4. Check for path separators (forward or back slash)
109-
// This catches paths like: subdirectory/file.png or subdirectory\file.png
110-
if (/[/\\]/.test(str)) {
111-
return true;
112-
}
113-
114113
// 5. Check for common image file extensions
115-
// This catches simple filenames like: screenshot.png
116-
// Common extensions: png, jpg, jpeg, gif, webp, bmp, svg, tiff, ico
114+
// This is the safest check - base64 never ends with .png/.jpg/etc
115+
// Catches: /path/file.png, subdir/file.png, file.png
117116
if (/\.(png|jpe?g|gif|webp|bmp|svg|tiff?|ico)$/i.test(str)) {
118117
return true;
119118
}
120119

120+
// Note: We intentionally don't check for bare "/" prefix or "/" anywhere
121+
// because JPEG base64 starts with "/9j/" which would false-positive
122+
// File paths without extensions are rare for images and will fall through
123+
// to base64 detection, which is acceptable for backwards compat
124+
121125
return false;
122126
}
123127

@@ -129,14 +133,13 @@ export function looksLikeFilePath(str) {
129133
* - 'file-path': A file path (relative or absolute)
130134
* - 'unknown': Cannot determine (ambiguous or invalid)
131135
*
132-
* Strategy:
133-
* 1. First check if it's valid base64 (can contain / which might look like paths)
134-
* 2. Then check if it looks like a file path (more specific patterns)
135-
* 3. Otherwise return 'unknown'
136+
* Strategy (optimized for performance):
137+
* 1. Check for data URI prefix first (O(1), definitive)
138+
* 2. Check file path patterns (O(1) prefix/suffix checks)
139+
* 3. For large non-path strings, assume base64 (skip expensive validation)
140+
* 4. Only run full base64 validation on small ambiguous strings
136141
*
137-
* This order prevents base64 strings (which can contain /) from being
138-
* misidentified as file paths. Base64 validation is stricter and should
139-
* be checked first.
142+
* This avoids O(n) regex validation on large screenshot buffers.
140143
*
141144
* @param {string} str - String to detect
142145
* @returns {'base64' | 'file-path' | 'unknown'} Detected input type
@@ -153,16 +156,27 @@ export function detectImageInputType(str) {
153156
return 'unknown';
154157
}
155158

156-
// Check base64 FIRST - base64 strings can contain / which looks like paths
157-
// Base64 validation is stricter and more deterministic
158-
if (isBase64(str)) {
159+
// 1. Data URIs are definitively base64 (O(1) check)
160+
if (str.startsWith('data:')) {
159161
return 'base64';
160162
}
161163

162-
// Then check file path - catch patterns that aren't valid base64
164+
// 2. Check file path patterns (O(1) prefix/suffix checks)
163165
if (looksLikeFilePath(str)) {
164166
return 'file-path';
165167
}
166168

169+
// 3. For large strings that aren't file paths, assume base64
170+
// Screenshots are typically 100KB+ as base64, file paths are <1KB
171+
// Skip expensive O(n) validation for large strings
172+
if (str.length > 1000) {
173+
return 'base64';
174+
}
175+
176+
// 4. Full validation only for small ambiguous strings
177+
if (isBase64(str)) {
178+
return 'base64';
179+
}
180+
167181
return 'unknown';
168182
}

tests/server/handlers/api-handler.test.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,98 @@ describe('server/handlers/api-handler', () => {
166166
assert.ok(result.body.error.includes('Invalid image input'));
167167
});
168168

169+
it('uses explicit base64 type parameter', async () => {
170+
let mockClient = { request: async () => ({}) };
171+
let handler = createApiHandler(mockClient, {
172+
uploadScreenshot: async () => ({ success: true }),
173+
});
174+
175+
let pngHeader = Buffer.from([
176+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
177+
]);
178+
let base64Image = pngHeader.toString('base64');
179+
180+
// Pass explicit type='base64'
181+
let result = await handler.handleScreenshot(
182+
'build-123',
183+
'test-with-type',
184+
base64Image,
185+
{},
186+
'base64'
187+
);
188+
189+
assert.strictEqual(result.statusCode, 200);
190+
assert.strictEqual(result.body.success, true);
191+
});
192+
193+
it('uses explicit file-path type parameter', async () => {
194+
let _uploadedData = null;
195+
let mockUploadScreenshot = async (
196+
_client,
197+
buildId,
198+
name,
199+
buffer,
200+
props
201+
) => {
202+
_uploadedData = { buildId, name, buffer, props };
203+
return { success: true };
204+
};
205+
206+
let mockClient = { request: async () => ({}) };
207+
let handler = createApiHandler(mockClient, {
208+
uploadScreenshot: mockUploadScreenshot,
209+
});
210+
211+
// Create test image file
212+
let { mkdtempSync, writeFileSync, rmSync } = await import('node:fs');
213+
let { tmpdir } = await import('node:os');
214+
let { join } = await import('node:path');
215+
let testDir = mkdtempSync(join(tmpdir(), 'api-handler-test-'));
216+
217+
let imagePath = join(testDir, 'test.png');
218+
let imageData = Buffer.from([0x89, 0x50, 0x4e, 0x47]);
219+
writeFileSync(imagePath, imageData);
220+
221+
// Pass explicit type='file-path'
222+
let result = await handler.handleScreenshot(
223+
'build-123',
224+
'file-screenshot',
225+
`file://${imagePath}`,
226+
{},
227+
'file-path'
228+
);
229+
230+
assert.strictEqual(result.statusCode, 200);
231+
assert.strictEqual(result.body.success, true);
232+
233+
await handler.flush();
234+
rmSync(testDir, { recursive: true });
235+
});
236+
237+
it('falls back to detection when type not provided', async () => {
238+
let mockClient = { request: async () => ({}) };
239+
let handler = createApiHandler(mockClient, {
240+
uploadScreenshot: async () => ({ success: true }),
241+
});
242+
243+
let pngHeader = Buffer.from([
244+
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
245+
]);
246+
let base64Image = pngHeader.toString('base64');
247+
248+
// No type parameter - relies on detection
249+
let result = await handler.handleScreenshot(
250+
'build-123',
251+
'test-no-type',
252+
base64Image,
253+
{}
254+
// No type parameter
255+
);
256+
257+
assert.strictEqual(result.statusCode, 200);
258+
assert.strictEqual(result.body.success, true);
259+
});
260+
169261
it('increments screenshot count', async () => {
170262
let mockClient = { request: async () => ({}) };
171263
let handler = createApiHandler(mockClient, {

0 commit comments

Comments
 (0)