get screenshot from a get request

This commit is contained in:
Nate Kelley 2025-10-08 12:57:12 -06:00
parent 28c7cbebb2
commit 6d18a031c5
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
33 changed files with 555 additions and 287 deletions

View File

@ -35,6 +35,7 @@
"@buster/test-utils": "workspace:*",
"@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*",
"@buster-app/trigger": "workspace:*",
"@electric-sql/client": "catalog:",
"@hono/zod-validator": "^0.7.3",
"@octokit/webhooks": "^14.1.3",

View File

@ -4,11 +4,11 @@ import {
GetChatScreenshotParamsSchema,
GetChatScreenshotQuerySchema,
} from '@buster/server-shared/screenshots';
import { getChatScreenshot } from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { createImageResponse } from '../../../../../shared-helpers/create-image-response';
import { getChatScreenshotHandler } from './getChatScreenshotHandler';
const app = new Hono().get(
'/',
@ -40,10 +40,15 @@ const app = new Hono().get(
}
try {
const screenshotBuffer = await getChatScreenshotHandler({
params: { id: chatId },
search,
context: c,
const screenshotBuffer = await getChatScreenshot({
chatId,
width: search.width,
height: search.height,
type: search.type,
supabaseCookieKey: c.get('supabaseCookieKey'),
supabaseUser: c.get('supabaseUser'),
accessToken: c.get('accessToken') || '',
organizationId: chat.organizationId,
});
return createImageResponse(screenshotBuffer, search.type);

View File

@ -1,37 +0,0 @@
import type {
GetChatScreenshotParams,
GetChatScreenshotQuery,
} from '@buster/server-shared/screenshots';
import type { Context } from 'hono';
import { createHrefFromLink } from '../../../../../shared-helpers/create-href-from-link';
export const getChatScreenshotHandler = async ({
params,
search,
context,
}: {
params: GetChatScreenshotParams;
search: GetChatScreenshotQuery;
context: Context;
}) => {
const { width, height, type } = search;
const { id: chatId } = params;
const { browserLogin } = await import('../../../../../shared-helpers/browser-login');
const { result: screenshotBuffer } = await browserLogin({
width,
height,
fullPath: createHrefFromLink({
to: '/screenshots/chats/$chatId/content' as const,
params: { chatId },
search: { type, width, height },
}),
context,
callback: async ({ page }) => {
return await page.screenshot({ type });
},
});
return screenshotBuffer;
};

View File

@ -1,15 +1,15 @@
import { checkPermission } from '@buster/access-controls';
import { getDashboardById } from '@buster/database/queries';
import { getDashboardById, getUserOrganizationId } from '@buster/database/queries';
import {
GetDashboardScreenshotParamsSchema,
GetDashboardScreenshotQuerySchema,
} from '@buster/server-shared/screenshots';
import { getDashboardScreenshot } from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { createImageResponse } from '../../../../../shared-helpers/create-image-response';
import { standardErrorHandler } from '../../../../../utils/response';
import { getDashboardScreenshotHandler } from './getDashboardScreenshotHandler';
const app = new Hono()
.get(
@ -43,10 +43,13 @@ const app = new Hono()
}
try {
const screenshotBuffer = await getDashboardScreenshotHandler({
params: { id: dashboardId },
search,
context: c,
const screenshotBuffer = await getDashboardScreenshot({
...search,
dashboardId,
supabaseCookieKey: c.get('supabaseCookieKey'),
supabaseUser: c.get('supabaseUser'),
accessToken: c.get('accessToken') || '',
organizationId: dashboard.organizationId,
});
return createImageResponse(screenshotBuffer, search.type);

View File

@ -1,37 +0,0 @@
import type {
GetDashboardScreenshotParams,
GetDashboardScreenshotQuery,
} from '@buster/server-shared/screenshots';
import type { Context } from 'hono';
import { createHrefFromLink } from '../../../../../shared-helpers/create-href-from-link';
export const getDashboardScreenshotHandler = async ({
params,
search,
context,
}: {
params: GetDashboardScreenshotParams;
search: GetDashboardScreenshotQuery;
context: Context;
}) => {
const { width, height, type } = search;
const { id: dashboardId } = params;
const { browserLogin } = await import('../../../../../shared-helpers/browser-login');
const { result: screenshotBuffer } = await browserLogin({
width,
height,
fullPath: createHrefFromLink({
to: '/screenshots/dashboards/$dashboardId/content' as const,
params: { dashboardId },
search: { type, width, height },
}),
context,
callback: async ({ page }) => {
return await page.screenshot({ type });
},
});
return screenshotBuffer;
};

View File

@ -1,12 +1,14 @@
import { screenshots_task_keys } from '@buster-app/trigger/task-keys';
import type { TakeMetricScreenshotTrigger } from '@buster-app/trigger/task-schemas';
import { getUserOrganizationId } from '@buster/database/queries';
import { MetricDataParamsSchema, MetricDataQuerySchema } from '@buster/server-shared';
import { zValidator } from '@hono/zod-validator';
import { tasks } from '@trigger.dev/sdk';
import { Hono } from 'hono';
import { standardErrorHandler } from '../../../../../utils/response';
import { saveMetricScreenshotHandler } from '../screenshot/saveMetricScreenshotHandler';
import { getMetricDataHandler } from './get-metric-data';
const app = new Hono()
// GET /metric_files/:id/data - Get metric data with pagination
.get(
'/',
zValidator('param', MetricDataParamsSchema),
@ -25,12 +27,21 @@ const app = new Hono()
password
);
saveMetricScreenshotHandler({
metricId: id,
version_number,
isOnSaveEvent: false,
context: c,
});
await tasks.trigger(
screenshots_task_keys.take_metric_screenshot,
{
metricId: id,
isOnSaveEvent: true,
supabaseCookieKey: c.get('supabaseCookieKey'),
supabaseUser: c.get('supabaseUser'),
accessToken: c.get('accessToken') || '',
organizationId:
(await getUserOrganizationId(user.id).then((res) => res?.organizationId)) || '',
} satisfies TakeMetricScreenshotTrigger,
{
idempotencyKey: `take-metric-screenshot-${id}`,
}
);
return c.json(response);
}

View File

@ -4,12 +4,12 @@ import {
GetMetricScreenshotParamsSchema,
GetMetricScreenshotQuerySchema,
} from '@buster/server-shared/screenshots';
import { getMetricScreenshot } from '@buster/server-shared/screenshots';
import { zValidator } from '@hono/zod-validator';
import { createImageResponse } from '@shared-helpers/create-image-response';
import { Hono } from 'hono';
import { HTTPException } from 'hono/http-exception';
import { createImageResponse } from '../../../../../shared-helpers/create-image-response';
import { standardErrorHandler } from '../../../../../utils/response';
import { getMetricScreenshotHandler } from './getMetricScreenshotHandler';
const app = new Hono()
.get(
@ -42,12 +42,16 @@ const app = new Hono()
}
try {
const screenshotBuffer = await getMetricScreenshotHandler({
const screenshotBuffer = await getMetricScreenshot({
metricId,
width,
height,
version_number,
context: c,
type,
supabaseCookieKey: c.get('supabaseCookieKey'),
supabaseUser: c.get('supabaseUser'),
accessToken: c.get('accessToken') || '',
organizationId: metric.organizationId,
});
return createImageResponse(screenshotBuffer, type);

View File

@ -1,34 +0,0 @@
import type { BrowserParamsContextOrDirectRequest } from '@shared-helpers/browser-login';
import { createHrefFromLink } from '@shared-helpers/create-href-from-link';
import { DEFAULT_SCREENSHOT_CONFIG } from '@shared-helpers/screenshot-config';
type GetMetricScreenshotHandlerArgs = {
metricId: string;
width: number;
height: number;
version_number: number | undefined;
type?: 'png' | 'jpeg';
} & BrowserParamsContextOrDirectRequest;
export const getMetricScreenshotHandler = async (args: GetMetricScreenshotHandlerArgs) => {
const { browserLogin } = await import('../../../../../shared-helpers/browser-login');
const { width, height, type = DEFAULT_SCREENSHOT_CONFIG.type } = args;
const { result: screenshotBuffer } = await browserLogin({
...args,
fullPath: createHrefFromLink({
to: '/screenshots/metrics/$metricId/content' as const,
params: { metricId: args.metricId },
search: {
version_number: args.version_number,
width,
height,
},
}),
callback: async ({ page }) => {
return await page.screenshot({ type });
},
});
return screenshotBuffer;
};

View File

@ -1,91 +0,0 @@
import {
getUserOrganizationId,
hasMetricScreenshotBeenTakenWithin,
} from '@buster/database/queries';
import type { BrowserParamsContextOrDirectRequest } from '@shared-helpers/browser-login';
import { DEFAULT_SCREENSHOT_CONFIG } from '@shared-helpers/screenshot-config';
import { uploadScreenshotHandler } from '@shared-helpers/upload-screenshot-handler';
import dayjs from 'dayjs';
import { getMetricScreenshotHandler } from './getMetricScreenshotHandler';
const shouldTakenNewScreenshot = async ({
metricId,
isOnSaveEvent,
}: { metricId: string; isOnSaveEvent: boolean }) => {
if (isOnSaveEvent) {
return true;
}
const isScreenshotExpired = await hasMetricScreenshotBeenTakenWithin(
metricId,
dayjs().subtract(6, 'hours')
);
return !isScreenshotExpired;
};
type SaveMetricScreenshotHandlerArgs = {
metricId: string;
version_number: number | undefined;
isOnSaveEvent: boolean;
} & BrowserParamsContextOrDirectRequest;
const activelyCapturingScreenshot = new Set<string>();
export const saveMetricScreenshotHandler = async (args: SaveMetricScreenshotHandlerArgs) => {
try {
const { isOnSaveEvent, metricId } = args;
if (activelyCapturingScreenshot.has(metricId)) {
return;
}
console.log('activelyCapturingScreenshot', activelyCapturingScreenshot);
const shouldTakeNewScreenshot = await shouldTakenNewScreenshot({
metricId,
isOnSaveEvent,
});
if (!shouldTakeNewScreenshot) {
return;
}
activelyCapturingScreenshot.add(metricId);
const organizationId =
'context' in args
? args.context.get('userOrganizationInfo')?.organizationId ||
(await getUserOrganizationId(args.context.get('busterUser')?.id))?.organizationId
: args.organizationId;
if (!organizationId) {
return {
success: false,
};
}
const screenshotBuffer = await getMetricScreenshotHandler({
...args,
width: DEFAULT_SCREENSHOT_CONFIG.width,
height: DEFAULT_SCREENSHOT_CONFIG.height,
});
console.log('screenshotBuffer', screenshotBuffer.length);
const result = await uploadScreenshotHandler({
assetType: 'metric_file',
assetId: metricId,
image: screenshotBuffer,
organizationId,
});
return result;
} catch (error) {
console.error('Error in saveMetricScreenshotHandler:', error);
return {
success: false,
};
} finally {
activelyCapturingScreenshot.delete(args.metricId);
}
};

View File

@ -1,8 +1,6 @@
import { DEFAULT_SCREENSHOT_CONFIG } from './screenshot-config';
export const createImageResponse = (
imageBuffer: Buffer,
type: 'png' | 'jpeg' = DEFAULT_SCREENSHOT_CONFIG.type
type: 'png' | 'jpeg' = 'png'
): Response => {
return new Response(imageBuffer, {
headers: {

View File

@ -102,6 +102,7 @@ describe('metric-helpers', () => {
deletedAt: null,
screenshotBucketKey: null,
savedToLibrary: false,
screenshotTakenAt: null,
...overrides,
});

View File

@ -3,6 +3,16 @@
"version": "1.0.0",
"private": false,
"type": "module",
"exports": {
"./task-keys": {
"types": "./src/task-keys.ts",
"default": "./src/task-keys.ts"
},
"./task-schemas": {
"types": "./src/task-schemas.ts",
"default": "./src/task-schemas.ts"
}
},
"scripts": {
"dev": "echo 'y' | npx trigger.dev@latest dev --env-file ../../.env",
"dev:fast": "echo 'y' | npx trigger.dev@latest dev",
@ -22,6 +32,7 @@
"dependencies": {
"@aws-sdk/client-s3": "catalog:",
"@aws-sdk/s3-request-presigner": "catalog:",
"@buster-app/supabase": "workspace:*",
"@buster/access-controls": "workspace:*",
"@buster/ai": "workspace:*",
"@buster/data-source": "workspace:^",
@ -29,19 +40,21 @@
"@buster/search": "workspace:*",
"@buster/server-shared": "workspace:*",
"@buster/slack": "workspace:*",
"@buster-app/supabase": "workspace:*",
"@buster/test-utils": "workspace:*",
"@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*",
"@buster/web-tools": "workspace:*",
"@duckdb/node-api": "1.3.2-alpha.26",
"@duckdb/node-bindings": "1.3.2-alpha.26",
"@supabase/supabase-js": "catalog:",
"@trigger.dev/sdk": "4.0.4",
"@types/js-yaml": "catalog:",
"ai": "catalog:",
"braintrust": "catalog:",
"dayjs": "^1.11.18",
"drizzle-orm": "catalog:",
"js-yaml": "catalog:",
"playwright": "^1.55.1",
"vitest": "catalog:",
"zod": "catalog:"
},

View File

@ -0,0 +1 @@
export * from './tasks/screenshots/task-keys';

View File

@ -0,0 +1,5 @@
//SCREENSHOTS
export {
TakeMetricScreenshotTriggerSchema,
type TakeMetricScreenshotTrigger,
} from './tasks/screenshots/schemas';

View File

@ -0,0 +1 @@
export { takeMetricScreenshotHandlerTask } from './take-metric-screenshot-handler';

View File

@ -0,0 +1,9 @@
import { GetMetricScreenshotHandlerArgsSchema } from '@buster/server-shared/screenshots';
import { z } from 'zod';
export const TakeMetricScreenshotTriggerSchema = GetMetricScreenshotHandlerArgsSchema.extend({
isOnSaveEvent: z.boolean(),
metricId: z.string(),
});
export type TakeMetricScreenshotTrigger = z.infer<typeof TakeMetricScreenshotTriggerSchema>;

View File

@ -0,0 +1,70 @@
import { hasMetricScreenshotBeenTakenWithin } from '@buster/database/queries';
import { getMetricScreenshot } from '@buster/server-shared/screenshots';
import { logger, schemaTask } from '@trigger.dev/sdk';
import dayjs from 'dayjs';
import { z } from 'zod';
import { TakeMetricScreenshotTriggerSchema } from './schemas';
import { screenshots_task_keys } from './task-keys';
import { uploadScreenshotHandler } from './upload-screenshot-handler';
export const takeMetricScreenshotHandlerTask: ReturnType<
typeof schemaTask<
typeof screenshots_task_keys.take_metric_screenshot,
typeof TakeMetricScreenshotTriggerSchema,
{ success: boolean } | undefined
>
> = schemaTask({
id: screenshots_task_keys.take_metric_screenshot,
schema: TakeMetricScreenshotTriggerSchema,
maxDuration: 60 * 3, // 3 minutes max
retry: {
maxAttempts: 1,
minTimeoutInMs: 1000, // 1 second
maxTimeoutInMs: 60 * 1000, // 1 minute
},
run: async (args) => {
logger.info('Getting metric screenshot', { args });
const { isOnSaveEvent, metricId, organizationId } = args;
const shouldTakeNewScreenshot = await shouldTakenNewScreenshot({
metricId,
isOnSaveEvent,
});
if (!shouldTakeNewScreenshot) {
return;
}
const screenshotBuffer = await getMetricScreenshot(args);
logger.info('Metric screenshot taken', { screenshotBufferLength: screenshotBuffer.length });
const result = await uploadScreenshotHandler({
assetType: 'metric_file',
assetId: metricId,
image: screenshotBuffer,
organizationId,
});
logger.info('Metric screenshot uploaded', { result });
return result;
},
});
const shouldTakenNewScreenshot = async ({
metricId,
isOnSaveEvent,
}: { metricId: string; isOnSaveEvent: boolean }) => {
if (isOnSaveEvent) {
return true;
}
const isScreenshotExpired = await hasMetricScreenshotBeenTakenWithin(
metricId,
dayjs().subtract(6, 'hours')
);
return !isScreenshotExpired;
};

View File

@ -0,0 +1,3 @@
export const screenshots_task_keys = {
take_metric_screenshot: 'take-metric-screenshot',
} as const;

View File

@ -0,0 +1,177 @@
import { getProviderForOrganization } from '@buster/data-source';
import { updateAssetScreenshotBucketKey } from '@buster/database/queries';
import type { AssetType } from '@buster/server-shared/assets';
import { AssetTypeSchema } from '@buster/server-shared/assets';
import {
PutChatScreenshotRequestSchema,
type PutScreenshotResponse,
PutScreenshotResponseSchema,
} from '@buster/server-shared/screenshots';
import z from 'zod';
export const UploadScreenshotParamsSchema = PutChatScreenshotRequestSchema.extend({
assetType: AssetTypeSchema,
assetId: z.string().uuid('Asset ID must be a valid UUID'),
organizationId: z.string().uuid('Organization ID must be a valid UUID'),
});
export type UploadScreenshotParams = z.infer<typeof UploadScreenshotParamsSchema>;
function getExtensionFromContentType(contentType: string): string {
switch (contentType) {
case 'image/jpeg':
case 'image/jpg':
return '.jpg';
case 'image/png':
return '.png';
case 'image/webp':
return '.webp';
default:
return '.png';
}
}
function parseBase64Image(base64Image: string): {
buffer: Buffer;
contentType: string;
extension: string;
} {
const dataUriPattern = /^data:(?<mime>[^;]+);base64,(?<data>.+)$/;
const match = base64Image.match(dataUriPattern);
const contentType = match?.groups?.mime ?? 'image/png';
const base64Data = match?.groups?.data ?? base64Image;
const buffer = Buffer.from(base64Data, 'base64');
if (buffer.length === 0) {
throw new Error('Provided image data is empty');
}
return {
buffer,
contentType,
extension: getExtensionFromContentType(contentType),
};
}
async function parseImageFile(file: File): Promise<{
buffer: Buffer;
contentType: string;
extension: string;
}> {
const arrayBuffer = await file.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
if (buffer.length === 0) {
throw new Error('Provided image file is empty');
}
return {
buffer,
contentType: file.type,
extension: getExtensionFromContentType(file.type),
};
}
function detectImageTypeFromBuffer(buffer: Buffer): { contentType: string; extension: string } {
// Check PNG signature (89 50 4E 47)
if (
buffer.length >= 4 &&
buffer[0] === 0x89 &&
buffer[1] === 0x50 &&
buffer[2] === 0x4e &&
buffer[3] === 0x47
) {
return { contentType: 'image/png', extension: '.png' };
}
// Check JPEG signature (FF D8 FF)
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
return { contentType: 'image/jpeg', extension: '.jpg' };
}
// Check WebP signature (RIFF ... WEBP)
if (
buffer.length >= 12 &&
buffer[0] === 0x52 &&
buffer[1] === 0x49 &&
buffer[2] === 0x46 &&
buffer[3] === 0x46 &&
buffer[8] === 0x57 &&
buffer[9] === 0x45 &&
buffer[10] === 0x42 &&
buffer[11] === 0x50
) {
return { contentType: 'image/webp', extension: '.webp' };
}
// Default to PNG if unknown
return { contentType: 'image/png', extension: '.png' };
}
function parseBuffer(buffer: Buffer): {
buffer: Buffer;
contentType: string;
extension: string;
} {
const { contentType, extension } = detectImageTypeFromBuffer(buffer);
return {
buffer,
contentType,
extension,
};
}
async function parseImageInput(image: string | File | Buffer): Promise<{
buffer: Buffer;
contentType: string;
extension: string;
}> {
if (Buffer.isBuffer(image)) {
return parseBuffer(image);
}
if (typeof image === 'string') {
return parseBase64Image(image);
}
return parseImageFile(image);
}
function buildScreenshotKey(
assetType: AssetType,
assetId: string,
extension: string,
organizationId: string
): string {
return `screenshots/${organizationId}/${assetType}-${assetId}${extension}`;
}
export async function uploadScreenshotHandler(
params: UploadScreenshotParams
): Promise<PutScreenshotResponse> {
const { assetType, assetId, image, organizationId } = UploadScreenshotParamsSchema.parse(params);
const { buffer, contentType, extension } = await parseImageInput(image);
const targetKey = buildScreenshotKey(assetType, assetId, extension, organizationId);
const provider = await getProviderForOrganization(organizationId);
const result = await provider.upload(targetKey, buffer, {
contentType,
});
if (!result.success) {
throw new Error(result.error ?? 'Failed to upload screenshot');
}
await updateAssetScreenshotBucketKey({
assetId,
assetType,
screenshotBucketKey: result.key,
});
return PutScreenshotResponseSchema.parse({
success: true,
bucketKey: result.key,
});
}

View File

@ -130,6 +130,8 @@
"@buster/database": "workspace:*",
"@buster/typescript-config": "workspace:*",
"@buster/vitest-config": "workspace:*",
"@supabase/supabase-js": "catalog:",
"playwright": "^1.55.1",
"zod": "catalog:"
},
"devDependencies": {

View File

@ -22,7 +22,6 @@ export * from './metrics';
export * from './organization';
export * from './public-chat';
export * from './s3-integrations';
export * from './screenshots';
export * from './search';
export * from './security';
// Export share module (has some naming conflicts with chats and metrics)

View File

@ -4,3 +4,4 @@ export * from './requests.dashboards';
export * from './requests.reports';
export * from './requests.chats';
export * from './requests.base';
export * from './methods';

View File

@ -1,29 +1,25 @@
import type { User } from '@supabase/supabase-js';
import type { Context } from 'hono';
import type { Browser, Page } from 'playwright';
import { z } from 'zod';
import { DEFAULT_SCREENSHOT_CONFIG } from './screenshot-config';
type BrowserParamsBase<T> = {
width: number | undefined;
height: number | undefined;
width?: number | undefined;
height?: number | undefined;
fullPath: string;
callback: ({ page, browser }: { page: Page; browser: Browser }) => Promise<T>;
};
type BrowserParamsContext = {
context: Context;
};
export const BrowserParamsContextSchema = z.object({
supabaseUser: z.any() as z.ZodType<User>,
supabaseCookieKey: z.string(),
accessToken: z.string(),
organizationId: z.string(),
});
type BrowserParamsDirectRequest = {
supabaseUser: User;
supabaseCookieKey: string;
accessToken: string;
organizationId: string;
};
export type BrowserParamsContext = z.infer<typeof BrowserParamsContextSchema>;
export type BrowserParamsContextOrDirectRequest = BrowserParamsContext | BrowserParamsDirectRequest;
export type BrowserParams<T = Buffer<ArrayBufferLike>> = BrowserParamsContextOrDirectRequest &
export type BrowserParams<T = Buffer<ArrayBufferLike>> = BrowserParamsContext &
BrowserParamsBase<T>;
export const browserLogin = async <T = Buffer<ArrayBufferLike>>({
@ -31,15 +27,10 @@ export const browserLogin = async <T = Buffer<ArrayBufferLike>>({
height = DEFAULT_SCREENSHOT_CONFIG.height,
fullPath,
callback,
...rest
supabaseUser,
supabaseCookieKey,
accessToken,
}: BrowserParams<T>) => {
const isContext = 'context' in rest;
const supabaseUser = isContext ? rest.context.get('supabaseUser') : rest.supabaseUser;
const supabaseCookieKey = isContext
? rest.context.get('supabaseCookieKey')
: rest.supabaseCookieKey;
const accessToken = isContext ? rest.context.get('accessToken') : rest.accessToken;
if (!accessToken) {
throw new Error('Missing Authorization header');
}

View File

@ -0,0 +1,33 @@
import { z } from 'zod';
import { BrowserParamsContextSchema, browserLogin } from './browser-login';
import { createHrefFromLink } from './create-href-from-link';
import { DEFAULT_SCREENSHOT_CONFIG } from './screenshot-config';
export const GetChatScreenshotHandlerArgsSchema = z
.object({
chatId: z.string().uuid('Chat ID must be a valid UUID'),
width: z.number().default(DEFAULT_SCREENSHOT_CONFIG.width).optional(),
height: z.number().default(DEFAULT_SCREENSHOT_CONFIG.height).optional(),
type: z.enum(['png', 'jpeg']).default(DEFAULT_SCREENSHOT_CONFIG.type).optional(),
})
.extend(BrowserParamsContextSchema.shape);
export type GetChatScreenshotHandlerArgs = z.infer<typeof GetChatScreenshotHandlerArgsSchema>;
export const getChatScreenshot = async (args: GetChatScreenshotHandlerArgs) => {
const { type = DEFAULT_SCREENSHOT_CONFIG.type } = args;
const { result: screenshotBuffer } = await browserLogin({
...args,
fullPath: createHrefFromLink({
to: '/screenshots/chats/$chatId/content' as const,
params: { chatId: args.chatId },
}),
callback: async ({ page }) => {
const screenshotBuffer = await page.screenshot({ type });
return screenshotBuffer;
},
});
return screenshotBuffer;
};

View File

@ -0,0 +1,39 @@
import { z } from 'zod';
import { BrowserParamsContextSchema, browserLogin } from './browser-login';
import { createHrefFromLink } from './create-href-from-link';
import { DEFAULT_SCREENSHOT_CONFIG } from './screenshot-config';
export const GetDashboardScreenshotHandlerArgsSchema = z
.object({
dashboardId: z.string().uuid('Dashboard ID must be a valid UUID'),
width: z.number().default(DEFAULT_SCREENSHOT_CONFIG.width).optional(),
height: z.number().default(DEFAULT_SCREENSHOT_CONFIG.height).optional(),
version_number: z.number().optional(),
type: z.enum(['png', 'jpeg']).default(DEFAULT_SCREENSHOT_CONFIG.type).optional(),
})
.extend(BrowserParamsContextSchema.shape);
export type GetDashboardScreenshotHandlerArgs = z.infer<
typeof GetDashboardScreenshotHandlerArgsSchema
>;
export const getDashboardScreenshot = async (args: GetDashboardScreenshotHandlerArgs) => {
const { type = DEFAULT_SCREENSHOT_CONFIG.type } = args;
const { result: screenshotBuffer } = await browserLogin({
...args,
fullPath: createHrefFromLink({
to: '/screenshots/dashboards/$dashboardId/content' as const,
params: { dashboardId: args.dashboardId },
search: {
version_number: args.version_number,
},
}),
callback: async ({ page }) => {
const screenshotBuffer = await page.screenshot({ type });
return screenshotBuffer;
},
});
return screenshotBuffer;
};

View File

@ -0,0 +1,39 @@
import { z } from 'zod';
import { BrowserParamsContextSchema, browserLogin } from './browser-login';
import { createHrefFromLink } from './create-href-from-link';
import { DEFAULT_SCREENSHOT_CONFIG } from './screenshot-config';
export const GetMetricScreenshotHandlerArgsSchema = z
.object({
metricId: z.string().uuid('Metric ID must be a valid UUID'),
width: z.number().default(DEFAULT_SCREENSHOT_CONFIG.width).optional(),
height: z.number().default(DEFAULT_SCREENSHOT_CONFIG.height).optional(),
version_number: z.number().optional(),
type: z.enum(['png', 'jpeg']).default(DEFAULT_SCREENSHOT_CONFIG.type).optional(),
})
.extend(BrowserParamsContextSchema.shape);
export type GetMetricScreenshotHandlerArgs = z.infer<typeof GetMetricScreenshotHandlerArgsSchema>;
export const getMetricScreenshot = async (
args: GetMetricScreenshotHandlerArgs
): Promise<Buffer<ArrayBufferLike>> => {
const { type = DEFAULT_SCREENSHOT_CONFIG.type } = args;
const { result: screenshotBuffer } = await browserLogin({
...args,
fullPath: createHrefFromLink({
to: '/screenshots/metrics/$metricId/content' as const,
params: { metricId: args.metricId },
search: {
version_number: args.version_number,
},
}),
callback: async ({ page }) => {
const screenshotBuffer = await page.screenshot({ type });
return screenshotBuffer;
},
});
return screenshotBuffer;
};

View File

@ -0,0 +1,37 @@
import { z } from 'zod';
import { BrowserParamsContextSchema, browserLogin } from './browser-login';
import { createHrefFromLink } from './create-href-from-link';
import { DEFAULT_SCREENSHOT_CONFIG } from './screenshot-config';
export const GetReportScreenshotHandlerArgsSchema = z
.object({
reportId: z.string().uuid('Report ID must be a valid UUID'),
width: z.number().default(DEFAULT_SCREENSHOT_CONFIG.width).optional(),
height: z.number().default(DEFAULT_SCREENSHOT_CONFIG.height).optional(),
version_number: z.number().optional(),
type: z.enum(['png', 'jpeg']).default(DEFAULT_SCREENSHOT_CONFIG.type).optional(),
})
.extend(BrowserParamsContextSchema.shape);
export type GetReportScreenshotHandlerArgs = z.infer<typeof GetReportScreenshotHandlerArgsSchema>;
export const getReportScreenshot = async (args: GetReportScreenshotHandlerArgs) => {
const { type = DEFAULT_SCREENSHOT_CONFIG.type } = args;
const { result: screenshotBuffer } = await browserLogin({
...args,
fullPath: createHrefFromLink({
to: '/screenshots/reports/$reportId/content' as const,
params: { reportId: args.reportId },
search: {
version_number: args.version_number,
},
}),
callback: async ({ page }) => {
const screenshotBuffer = await page.screenshot({ type });
return screenshotBuffer;
},
});
return screenshotBuffer;
};

View File

@ -0,0 +1,7 @@
export { getMetricScreenshot, GetMetricScreenshotHandlerArgsSchema } from './get-metric-screenshot';
export {
getDashboardScreenshot,
GetDashboardScreenshotHandlerArgsSchema,
} from './get-dashboard-screenshot';
export { getChatScreenshot, GetChatScreenshotHandlerArgsSchema } from './get-chat-screenshot';
export { getReportScreenshot, GetReportScreenshotHandlerArgsSchema } from './get-report-screenshot';

View File

@ -19,7 +19,7 @@ catalogs:
specifier: ^1.0.12
version: 1.0.12
'@supabase/supabase-js':
specifier: ^2.57.4
specifier: 2.57.4
version: 2.57.4
'@types/js-yaml':
specifier: 4.0.9
@ -243,6 +243,9 @@ importers:
apps/server:
dependencies:
'@buster-app/trigger':
specifier: workspace:*
version: link:../trigger
'@buster/access-controls':
specifier: workspace:*
version: link:../../packages/access-controls
@ -396,6 +399,9 @@ importers:
'@duckdb/node-bindings':
specifier: 1.3.2-alpha.26
version: 1.3.2-alpha.26
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.57.4
'@trigger.dev/sdk':
specifier: 4.0.4
version: 4.0.4(ai@5.0.44(zod@3.25.76))(zod@3.25.76)
@ -408,12 +414,18 @@ importers:
braintrust:
specifier: 'catalog:'
version: 0.3.7(@aws-sdk/credential-provider-web-identity@3.888.0)(zod@3.25.76)
dayjs:
specifier: ^1.11.18
version: 1.11.18
drizzle-orm:
specifier: 'catalog:'
version: 0.44.5(@opentelemetry/api@1.9.0)(@types/pg@8.15.4)(bun-types@1.2.21(@types/react@19.1.13))(mysql2@3.14.1)(pg@8.16.3)(postgres@3.4.7)
js-yaml:
specifier: 'catalog:'
version: 4.1.0
playwright:
specifier: ^1.55.1
version: 1.55.1
vitest:
specifier: 'catalog:'
version: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
@ -1347,6 +1359,12 @@ importers:
'@buster/vitest-config':
specifier: workspace:*
version: link:../vitest-config
'@supabase/supabase-js':
specifier: 'catalog:'
version: 2.57.4
playwright:
specifier: ^1.55.1
version: 1.55.1
zod:
specifier: 'catalog:'
version: 3.25.76
@ -19977,11 +19995,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitest/browser@3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.4(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)':
'@vitest/browser@3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(vite@7.1.4(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@vitest/utils': 3.2.4
magic-string: 0.30.17
sirv: 3.0.1
@ -20006,25 +20024,6 @@ snapshots:
magic-string: 0.30.17
sirv: 3.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
ws: 8.18.3
optionalDependencies:
playwright: 1.55.1
transitivePeerDependencies:
- bufferutil
- msw
- utf-8-validate
- vite
'@vitest/browser@3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.9(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.1
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.1)
'@vitest/mocker': 3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(vite@7.1.9(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))
'@vitest/utils': 3.2.4
magic-string: 0.30.17
sirv: 3.0.1
tinyrainbow: 2.0.0
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
ws: 8.18.3
optionalDependencies:
@ -20034,7 +20033,6 @@ snapshots:
- msw
- utf-8-validate
- vite
optional: true
'@vitest/coverage-v8@3.2.4(@vitest/browser@3.2.4)(vitest@3.2.4)':
dependencies:
@ -20074,6 +20072,16 @@ snapshots:
msw: 2.11.3(@types/node@22.18.1)(typescript@5.9.2)
vite: 7.1.4(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
'@vitest/mocker@3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
msw: 2.11.3(@types/node@22.18.1)(typescript@5.9.2)
vite: 7.1.9(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
optional: true
'@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(vite@7.1.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
@ -20083,16 +20091,6 @@ snapshots:
msw: 2.11.3(@types/node@24.3.1)(typescript@5.9.2)
vite: 7.1.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
'@vitest/mocker@3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(vite@7.1.9(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
msw: 2.11.3(@types/node@24.3.1)(typescript@5.9.2)
vite: 7.1.9(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
optional: true
'@vitest/pretty-format@3.2.4':
dependencies:
tinyrainbow: 2.0.0
@ -20122,7 +20120,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 2.0.0
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@22.18.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
vitest: 3.2.4(@edge-runtime/vm@3.2.0)(@types/debug@4.1.12)(@types/node@24.3.1)(@vitest/browser@3.2.4)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.0.0(postcss@8.5.6))(lightningcss@1.30.1)(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
'@vitest/utils@3.2.4':
dependencies:
@ -27383,6 +27381,25 @@ snapshots:
tsx: 4.20.5
yaml: 2.8.1
vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1):
dependencies:
esbuild: 0.25.9
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.50.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 22.18.1
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.1
sass: 1.93.2
terser: 5.43.1
tsx: 4.20.5
yaml: 2.8.1
optional: true
vite@7.1.9(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1):
dependencies:
esbuild: 0.25.9
@ -27444,7 +27461,7 @@ snapshots:
'@edge-runtime/vm': 3.2.0
'@types/debug': 4.1.12
'@types/node': 22.18.1
'@vitest/browser': 3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.4(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/browser': 3.2.4(msw@2.11.3(@types/node@22.18.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.9(@types/node@22.18.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 27.0.0(postcss@8.5.6)
transitivePeerDependencies:
@ -27536,7 +27553,7 @@ snapshots:
'@edge-runtime/vm': 3.2.0
'@types/debug': 4.1.12
'@types/node': 24.3.1
'@vitest/browser': 3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.9(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/browser': 3.2.4(msw@2.11.3(@types/node@24.3.1)(typescript@5.9.2))(playwright@1.55.1)(vite@7.1.4(@types/node@24.3.1)(jiti@2.6.1)(lightningcss@1.30.1)(sass@1.93.2)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1))(vitest@3.2.4)
'@vitest/ui': 3.2.4(vitest@3.2.4)
jsdom: 27.0.0(postcss@8.5.6)
transitivePeerDependencies:

View File

@ -14,7 +14,7 @@ catalog:
'@aws-sdk/s3-request-presigner': ^3.888.0
'@electric-sql/client': ^1.0.12
'@electric-sql/react': ^1.0.12
'@supabase/supabase-js': ^2.57.4
'@supabase/supabase-js': 2.57.4
'@trigger.dev/build': ^4.0.2
'@trigger.dev/sdk': ^4.0.2
'@types/js-yaml': 4.0.9