Skip to content

Commit c8728ff

Browse files
authored
refactor(browser): split browser cores via build ARG, add core selector (#237)
* refactor(browser): split browser cores via build ARG, add core selector - Replace playwright official image with ubuntu:noble base in both docker/Dockerfile.browser and devenv/Dockerfile.browser; install browsers at build time driven by ARG/ENV BROWSER_CORES - Add GET /cores endpoint to Browser Gateway reporting available cores - Proxy GET /browser-contexts/cores in Go handler to Browser Gateway - Add `core` field to BrowserContextConfigModel and GatewayBrowserContext; context creation selects the appropriate browser instance by core - Frontend context-setting page fetches available cores and renders a core selector; saves core as part of the config JSON - install.sh prompts for browser core selection and writes BROWSER_CORES to .env; builds the browser image locally before docker compose up - Regenerate OpenAPI spec and TypeScript SDK * fix: lint
1 parent 627b673 commit c8728ff

22 files changed

Lines changed: 364 additions & 33 deletions

File tree

apps/browser/src/browser.ts

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,36 @@
1-
import { chromium } from 'playwright'
1+
import { chromium, firefox } from 'playwright'
2+
import type { Browser } from 'playwright'
23

3-
export const initBrowser = async () => {
4-
return await chromium.launch({
5-
headless: true,
6-
})
7-
}
4+
export type BrowserCore = 'chromium' | 'firefox'
5+
6+
export const browsers = new Map<BrowserCore, Browser>()
7+
8+
export const initBrowsers = async (): Promise<Map<BrowserCore, Browser>> => {
9+
const raw = process.env.BROWSER_CORES ?? 'chromium'
10+
const cores = raw.split(',').map(s => s.trim()) as BrowserCore[]
11+
12+
for (const core of cores) {
13+
if (core === 'chromium') {
14+
browsers.set('chromium', await chromium.launch({ headless: true }))
15+
} else if (core === 'firefox') {
16+
browsers.set('firefox', await firefox.launch({ headless: true }))
17+
}
18+
}
19+
20+
if (browsers.size === 0) {
21+
browsers.set('chromium', await chromium.launch({ headless: true }))
22+
}
23+
24+
return browsers
25+
}
26+
27+
export const getBrowser = (core: BrowserCore = 'chromium'): Browser => {
28+
const b = browsers.get(core) ?? browsers.values().next().value
29+
if (!b) throw new Error(`Browser core "${core}" is not available`)
30+
return b
31+
}
32+
33+
export const getAvailableCores = (): BrowserCore[] => {
34+
const raw = process.env.BROWSER_CORES ?? 'chromium'
35+
return raw.split(',').map(s => s.trim()) as BrowserCore[]
36+
}

apps/browser/src/index.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,30 @@ import { Elysia } from 'elysia'
22
import { loadConfig } from '@memoh/config'
33
import { corsMiddleware } from './middlewares/cors'
44
import { errorMiddleware } from './middlewares/error'
5-
import { initBrowser } from './browser'
5+
import { initBrowsers, browsers } from './browser'
66
import { contextModule } from './modules/context'
77
import { devicesModule } from './modules/devices'
8+
import { coresModule } from './modules/cores'
89

910
const config = loadConfig('../../config.toml')
1011

11-
export const browser = await initBrowser()
12+
await initBrowsers()
13+
14+
export { browsers }
1215

1316
const app = new Elysia()
1417
.use(corsMiddleware)
1518
.use(errorMiddleware)
1619
.get('/health', () => ({
1720
status: 'ok',
1821
}))
22+
.use(coresModule)
1923
.use(contextModule)
2024
.use(devicesModule)
2125
.onStop(async () => {
22-
await browser.close()
26+
for (const browser of browsers.values()) {
27+
await browser.close()
28+
}
2329
})
2430
.listen({
2531
port: config.browser_gateway.port ?? 8083,
@@ -28,4 +34,3 @@ const app = new Elysia()
2834
})
2935

3036
console.log(`🌐 Browser Gateway is running at ${app.server!.url}`)
31-

apps/browser/src/models.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { z } from 'zod'
22

33
export const BrowserContextConfigModel = z.object({
4+
core: z.enum(['chromium', 'firefox']).optional().default('chromium'),
45
viewport: z.object({
56
width: z.number(),
67
height: z.number(),

apps/browser/src/modules/context.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
22
import { storage } from '../storage'
33
import { z } from 'zod'
44
import { BrowserContextConfigModel } from '../models'
5-
import { browser } from '..'
5+
import { getBrowser } from '../browser'
66
import { actionModule } from './action'
77

88
export const contextModule = new Elysia({ prefix: '/context' })
@@ -14,7 +14,7 @@ export const contextModule = new Elysia({ prefix: '/context' })
1414
const { id } = query
1515
const entry = storage.get(id)
1616
if (!entry) return null
17-
return { id: entry.id, name: entry.name, config: entry.config }
17+
return { id: entry.id, name: entry.name, core: entry.core, config: entry.config }
1818
}, {
1919
query: z.object({
2020
id: z.string(),
@@ -24,6 +24,8 @@ export const contextModule = new Elysia({ prefix: '/context' })
2424
'/',
2525
async ({ body }) => {
2626
const { name, config, id } = body
27+
const core = config.core ?? 'chromium'
28+
const browser = getBrowser(core)
2729
const context = await browser.newContext({
2830
viewport: config.viewport,
2931
userAgent: config.userAgent,
@@ -37,8 +39,8 @@ export const contextModule = new Elysia({ prefix: '/context' })
3739
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
3840
proxy: config.proxy,
3941
})
40-
storage.set(id, { id, name, context, config })
41-
return { id, name, config }
42+
storage.set(id, { id, name, core, context, config })
43+
return { id, name, core, config }
4244
},
4345
{
4446
body: z.object({

apps/browser/src/modules/cores.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { Elysia } from 'elysia'
2+
import { getAvailableCores } from '../browser'
3+
4+
export const coresModule = new Elysia({ prefix: '/cores' })
5+
.get('/', () => {
6+
return { cores: getAvailableCores() }
7+
})

apps/browser/src/types/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import type { BrowserContext, Page } from 'playwright'
22
import type { BrowserContextConfig } from '../models'
3+
import type { BrowserCore } from '../browser'
34

45
export interface GatewayBrowserContext {
56
id: string
67
name: string
8+
core: BrowserCore
79
context: BrowserContext
810
config: BrowserContextConfig
911
activePage?: Page

apps/web/src/i18n/locales/en.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,9 @@
393393
"timezoneId": "Timezone",
394394
"timezonePlaceholder": "e.g. America/New_York",
395395
"ignoreHTTPSErrors": "Ignore HTTPS Errors",
396+
"core": "Browser Core",
397+
"chromium": "Chromium",
398+
"firefox": "Firefox",
396399
"config": "Configuration"
397400
},
398401
"mcp": {

apps/web/src/i18n/locales/zh.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,9 @@
389389
"timezoneId": "时区",
390390
"timezonePlaceholder": "例如 Asia/Shanghai",
391391
"ignoreHTTPSErrors": "忽略 HTTPS 错误",
392+
"core": "浏览器内核",
393+
"chromium": "Chromium",
394+
"firefox": "Firefox",
392395
"config": "配置"
393396
},
394397
"mcp": {

apps/web/src/pages/browser-contexts/components/context-setting.vue

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,31 @@
4141
{{ $t('browserContext.config') }}
4242
</h3>
4343

44+
<FormField
45+
v-slot="{ value, handleChange }"
46+
name="core"
47+
>
48+
<FormItem>
49+
<Label>{{ $t('browserContext.core') }}</Label>
50+
<FormControl>
51+
<div class="flex gap-3">
52+
<button
53+
v-for="c in availableCores"
54+
:key="c"
55+
type="button"
56+
class="flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm transition-colors"
57+
:class="value === c
58+
? 'border-primary bg-primary/10 text-primary font-medium'
59+
: 'border-border bg-card text-muted-foreground hover:bg-accent'"
60+
@click="handleChange(c)"
61+
>
62+
{{ $t(`browserContext.${c}`) }}
63+
</button>
64+
</div>
65+
</FormControl>
66+
</FormItem>
67+
</FormField>
68+
4469
<div class="grid grid-cols-2 gap-4">
4570
<FormField
4671
v-slot="{ componentField }"
@@ -212,10 +237,11 @@ import {
212237
import { toTypedSchema } from '@vee-validate/zod'
213238
import z from 'zod'
214239
import { useForm } from 'vee-validate'
215-
import { useMutation, useQueryCache } from '@pinia/colada'
240+
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
216241
import { putBrowserContextsById, deleteBrowserContextsById } from '@memoh/sdk'
242+
import { getBrowserContextsCoresQuery } from '@memoh/sdk/colada'
217243
import type { BrowsercontextsBrowserContext } from '@memoh/sdk'
218-
import { inject, watch, type Ref } from 'vue'
244+
import { inject, watch, computed, type Ref } from 'vue'
219245
import { useI18n } from 'vue-i18n'
220246
import { toast } from 'vue-sonner'
221247
import { useDialogMutation } from '@/composables/useDialogMutation'
@@ -228,7 +254,11 @@ const queryCache = useQueryCache()
228254
229255
const curContext = inject<Ref<BrowsercontextsBrowserContext | undefined>>('curBrowserContext')
230256
257+
const { data: coresData } = useQuery(getBrowserContextsCoresQuery())
258+
const availableCores = computed(() => coresData.value?.cores ?? ['chromium'])
259+
231260
interface ConfigShape {
261+
core?: string
232262
viewport?: { width?: number; height?: number }
233263
userAgent?: string
234264
deviceScaleFactor?: number
@@ -251,6 +281,7 @@ function parseConfig(ctx: BrowsercontextsBrowserContext | undefined): ConfigShap
251281
252282
const schema = toTypedSchema(z.object({
253283
name: z.string().min(1),
284+
core: z.enum(['chromium', 'firefox']).optional(),
254285
viewportWidth: z.coerce.number().optional(),
255286
viewportHeight: z.coerce.number().optional(),
256287
userAgent: z.string().optional(),
@@ -269,6 +300,7 @@ watch(() => curContext?.value, (ctx) => {
269300
form.resetForm({
270301
values: {
271302
name: ctx.name || '',
303+
core: (cfg.core as 'chromium' | 'firefox') ?? 'chromium',
272304
viewportWidth: cfg.viewport?.width ?? 1280,
273305
viewportHeight: cfg.viewport?.height ?? 720,
274306
userAgent: cfg.userAgent ?? '',
@@ -307,7 +339,9 @@ const handleSave = form.handleSubmit(async (values) => {
307339
const id = curContext?.value?.id
308340
if (!id) return
309341
310-
const config: Record<string, any> = {}
342+
const config: Record<string, any> = {
343+
core: values.core ?? 'chromium',
344+
}
311345
if (values.viewportWidth || values.viewportHeight) {
312346
config.viewport = {
313347
width: values.viewportWidth || 1280,

devenv/Dockerfile.browser

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,17 @@
1-
FROM mcr.microsoft.com/playwright:v1.50.0-noble
1+
FROM ubuntu:noble
2+
3+
ARG BROWSER_CORES=chromium,firefox
4+
ENV BROWSER_CORES=$BROWSER_CORES
5+
6+
RUN apt-get update && apt-get install -y --no-install-recommends unzip curl nodejs npm && \
7+
curl -fsSL https://bun.sh/install | bash && \
8+
npm install -g playwright@1.50.0 && \
9+
for core in $(echo "$BROWSER_CORES" | tr ',' ' '); do \
10+
npx playwright install --with-deps "$core"; \
11+
done && \
12+
npm uninstall -g playwright && \
13+
apt-get clean && rm -rf /var/lib/apt/lists/*
214

3-
RUN apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*
4-
RUN curl -fsSL https://bun.sh/install | bash
515
ENV PATH="/root/.bun/bin:${PATH}"
616

717
WORKDIR /workspace

0 commit comments

Comments
 (0)