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
This commit is contained in:
Acbox Liu
2026-03-14 12:37:20 +08:00
committed by GitHub
parent 627b673a5c
commit c8728ffc2c
22 changed files with 364 additions and 33 deletions
+34 -5
View File
@@ -1,7 +1,36 @@
import { chromium } from 'playwright'
import { chromium, firefox } from 'playwright'
import type { Browser } from 'playwright'
export const initBrowser = async () => {
return await chromium.launch({
headless: true,
})
export type BrowserCore = 'chromium' | 'firefox'
export const browsers = new Map<BrowserCore, Browser>()
export const initBrowsers = async (): Promise<Map<BrowserCore, Browser>> => {
const raw = process.env.BROWSER_CORES ?? 'chromium'
const cores = raw.split(',').map(s => s.trim()) as BrowserCore[]
for (const core of cores) {
if (core === 'chromium') {
browsers.set('chromium', await chromium.launch({ headless: true }))
} else if (core === 'firefox') {
browsers.set('firefox', await firefox.launch({ headless: true }))
}
}
if (browsers.size === 0) {
browsers.set('chromium', await chromium.launch({ headless: true }))
}
return browsers
}
export const getBrowser = (core: BrowserCore = 'chromium'): Browser => {
const b = browsers.get(core) ?? browsers.values().next().value
if (!b) throw new Error(`Browser core "${core}" is not available`)
return b
}
export const getAvailableCores = (): BrowserCore[] => {
const raw = process.env.BROWSER_CORES ?? 'chromium'
return raw.split(',').map(s => s.trim()) as BrowserCore[]
}
+8 -3
View File
@@ -2,13 +2,16 @@ import { Elysia } from 'elysia'
import { loadConfig } from '@memoh/config'
import { corsMiddleware } from './middlewares/cors'
import { errorMiddleware } from './middlewares/error'
import { initBrowser } from './browser'
import { initBrowsers, browsers } from './browser'
import { contextModule } from './modules/context'
import { devicesModule } from './modules/devices'
import { coresModule } from './modules/cores'
const config = loadConfig('../../config.toml')
export const browser = await initBrowser()
await initBrowsers()
export { browsers }
const app = new Elysia()
.use(corsMiddleware)
@@ -16,10 +19,13 @@ const app = new Elysia()
.get('/health', () => ({
status: 'ok',
}))
.use(coresModule)
.use(contextModule)
.use(devicesModule)
.onStop(async () => {
for (const browser of browsers.values()) {
await browser.close()
}
})
.listen({
port: config.browser_gateway.port ?? 8083,
@@ -28,4 +34,3 @@ const app = new Elysia()
})
console.log(`🌐 Browser Gateway is running at ${app.server!.url}`)
+1
View File
@@ -1,6 +1,7 @@
import { z } from 'zod'
export const BrowserContextConfigModel = z.object({
core: z.enum(['chromium', 'firefox']).optional().default('chromium'),
viewport: z.object({
width: z.number(),
height: z.number(),
+6 -4
View File
@@ -2,7 +2,7 @@ import { Elysia } from 'elysia'
import { storage } from '../storage'
import { z } from 'zod'
import { BrowserContextConfigModel } from '../models'
import { browser } from '..'
import { getBrowser } from '../browser'
import { actionModule } from './action'
export const contextModule = new Elysia({ prefix: '/context' })
@@ -14,7 +14,7 @@ export const contextModule = new Elysia({ prefix: '/context' })
const { id } = query
const entry = storage.get(id)
if (!entry) return null
return { id: entry.id, name: entry.name, config: entry.config }
return { id: entry.id, name: entry.name, core: entry.core, config: entry.config }
}, {
query: z.object({
id: z.string(),
@@ -24,6 +24,8 @@ export const contextModule = new Elysia({ prefix: '/context' })
'/',
async ({ body }) => {
const { name, config, id } = body
const core = config.core ?? 'chromium'
const browser = getBrowser(core)
const context = await browser.newContext({
viewport: config.viewport,
userAgent: config.userAgent,
@@ -37,8 +39,8 @@ export const contextModule = new Elysia({ prefix: '/context' })
ignoreHTTPSErrors: config.ignoreHTTPSErrors,
proxy: config.proxy,
})
storage.set(id, { id, name, context, config })
return { id, name, config }
storage.set(id, { id, name, core, context, config })
return { id, name, core, config }
},
{
body: z.object({
+7
View File
@@ -0,0 +1,7 @@
import { Elysia } from 'elysia'
import { getAvailableCores } from '../browser'
export const coresModule = new Elysia({ prefix: '/cores' })
.get('/', () => {
return { cores: getAvailableCores() }
})
+2
View File
@@ -1,9 +1,11 @@
import type { BrowserContext, Page } from 'playwright'
import type { BrowserContextConfig } from '../models'
import type { BrowserCore } from '../browser'
export interface GatewayBrowserContext {
id: string
name: string
core: BrowserCore
context: BrowserContext
config: BrowserContextConfig
activePage?: Page
+3
View File
@@ -393,6 +393,9 @@
"timezoneId": "Timezone",
"timezonePlaceholder": "e.g. America/New_York",
"ignoreHTTPSErrors": "Ignore HTTPS Errors",
"core": "Browser Core",
"chromium": "Chromium",
"firefox": "Firefox",
"config": "Configuration"
},
"mcp": {
+3
View File
@@ -389,6 +389,9 @@
"timezoneId": "时区",
"timezonePlaceholder": "例如 Asia/Shanghai",
"ignoreHTTPSErrors": "忽略 HTTPS 错误",
"core": "浏览器内核",
"chromium": "Chromium",
"firefox": "Firefox",
"config": "配置"
},
"mcp": {
@@ -41,6 +41,31 @@
{{ $t('browserContext.config') }}
</h3>
<FormField
v-slot="{ value, handleChange }"
name="core"
>
<FormItem>
<Label>{{ $t('browserContext.core') }}</Label>
<FormControl>
<div class="flex gap-3">
<button
v-for="c in availableCores"
:key="c"
type="button"
class="flex items-center gap-2 px-3 py-1.5 rounded-md border text-sm transition-colors"
:class="value === c
? 'border-primary bg-primary/10 text-primary font-medium'
: 'border-border bg-card text-muted-foreground hover:bg-accent'"
@click="handleChange(c)"
>
{{ $t(`browserContext.${c}`) }}
</button>
</div>
</FormControl>
</FormItem>
</FormField>
<div class="grid grid-cols-2 gap-4">
<FormField
v-slot="{ componentField }"
@@ -212,10 +237,11 @@ import {
import { toTypedSchema } from '@vee-validate/zod'
import z from 'zod'
import { useForm } from 'vee-validate'
import { useMutation, useQueryCache } from '@pinia/colada'
import { useMutation, useQuery, useQueryCache } from '@pinia/colada'
import { putBrowserContextsById, deleteBrowserContextsById } from '@memoh/sdk'
import { getBrowserContextsCoresQuery } from '@memoh/sdk/colada'
import type { BrowsercontextsBrowserContext } from '@memoh/sdk'
import { inject, watch, type Ref } from 'vue'
import { inject, watch, computed, type Ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { useDialogMutation } from '@/composables/useDialogMutation'
@@ -228,7 +254,11 @@ const queryCache = useQueryCache()
const curContext = inject<Ref<BrowsercontextsBrowserContext | undefined>>('curBrowserContext')
const { data: coresData } = useQuery(getBrowserContextsCoresQuery())
const availableCores = computed(() => coresData.value?.cores ?? ['chromium'])
interface ConfigShape {
core?: string
viewport?: { width?: number; height?: number }
userAgent?: string
deviceScaleFactor?: number
@@ -251,6 +281,7 @@ function parseConfig(ctx: BrowsercontextsBrowserContext | undefined): ConfigShap
const schema = toTypedSchema(z.object({
name: z.string().min(1),
core: z.enum(['chromium', 'firefox']).optional(),
viewportWidth: z.coerce.number().optional(),
viewportHeight: z.coerce.number().optional(),
userAgent: z.string().optional(),
@@ -269,6 +300,7 @@ watch(() => curContext?.value, (ctx) => {
form.resetForm({
values: {
name: ctx.name || '',
core: (cfg.core as 'chromium' | 'firefox') ?? 'chromium',
viewportWidth: cfg.viewport?.width ?? 1280,
viewportHeight: cfg.viewport?.height ?? 720,
userAgent: cfg.userAgent ?? '',
@@ -307,7 +339,9 @@ const handleSave = form.handleSubmit(async (values) => {
const id = curContext?.value?.id
if (!id) return
const config: Record<string, any> = {}
const config: Record<string, any> = {
core: values.core ?? 'chromium',
}
if (values.viewportWidth || values.viewportHeight) {
config.viewport = {
width: values.viewportWidth || 1280,
+13 -3
View File
@@ -1,7 +1,17 @@
FROM mcr.microsoft.com/playwright:v1.50.0-noble
FROM ubuntu:noble
ARG BROWSER_CORES=chromium,firefox
ENV BROWSER_CORES=$BROWSER_CORES
RUN apt-get update && apt-get install -y --no-install-recommends unzip curl nodejs npm && \
curl -fsSL https://bun.sh/install | bash && \
npm install -g playwright@1.50.0 && \
for core in $(echo "$BROWSER_CORES" | tr ',' ' '); do \
npx playwright install --with-deps "$core"; \
done && \
npm uninstall -g playwright && \
apt-get clean && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends unzip && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
WORKDIR /workspace
+4
View File
@@ -141,9 +141,13 @@ services:
build:
context: ..
dockerfile: devenv/Dockerfile.browser
args:
BROWSER_CORES: ${BROWSER_CORES:-chromium,firefox}
container_name: memoh-dev-browser
working_dir: /workspace/apps/browser
command: ["bun", "run", "--watch", "src/index.ts"]
environment:
- BROWSER_CORES=${BROWSER_CORES:-chromium,firefox}
volumes:
- ..:/workspace
- node_modules:/workspace/node_modules
+7
View File
@@ -118,8 +118,15 @@ services:
browser:
image: memohai/browser:latest
build:
context: .
dockerfile: docker/Dockerfile.browser
args:
BROWSER_CORES: ${BROWSER_CORES:-chromium,firefox}
container_name: memoh-browser
profiles: [browser]
environment:
- BROWSER_CORES=${BROWSER_CORES:-chromium,firefox}
volumes:
- ${MEMOH_CONFIG:-./config.toml}:/config.toml:ro
ports:
+7 -2
View File
@@ -15,7 +15,10 @@ COPY apps/browser/ ./apps/browser/
RUN cd apps/browser && bun run build
FROM mcr.microsoft.com/playwright:v1.50.0-noble
FROM ubuntu:noble
ARG BROWSER_CORES=chromium,firefox
ENV BROWSER_CORES=$BROWSER_CORES
WORKDIR /app
@@ -29,7 +32,9 @@ COPY --from=builder /build/apps/browser/node_modules /app/node_modules
COPY --from=builder /build/apps/browser/package.json /app/package.json
COPY --from=builder /build/node_modules /node_modules
RUN npx playwright install --with-deps chromium
RUN for core in $(echo "$BROWSER_CORES" | tr ',' ' '); do \
bun /app/node_modules/.bin/playwright install --with-deps "$core"; \
done
EXPOSE 8083
+46 -1
View File
@@ -1,6 +1,9 @@
package handlers
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strings"
@@ -8,17 +11,20 @@ import (
"github.com/labstack/echo/v4"
"github.com/memohai/memoh/internal/browsercontexts"
"github.com/memohai/memoh/internal/config"
)
type BrowserContextsHandler struct {
service *browsercontexts.Service
logger *slog.Logger
browserGatewayURL string
}
func NewBrowserContextsHandler(log *slog.Logger, service *browsercontexts.Service) *BrowserContextsHandler {
func NewBrowserContextsHandler(log *slog.Logger, service *browsercontexts.Service, cfg config.Config) *BrowserContextsHandler {
return &BrowserContextsHandler{
service: service,
logger: log.With(slog.String("handler", "browser_contexts")),
browserGatewayURL: cfg.BrowserGateway.BaseURL(),
}
}
@@ -26,6 +32,7 @@ func (h *BrowserContextsHandler) Register(e *echo.Echo) {
group := e.Group("/browser-contexts")
group.POST("", h.Create)
group.GET("", h.List)
group.GET("/cores", h.GetCores)
group.GET("/:id", h.Get)
group.PUT("/:id", h.Update)
group.DELETE("/:id", h.Delete)
@@ -139,3 +146,41 @@ func (h *BrowserContextsHandler) Delete(c echo.Context) error {
}
return c.NoContent(http.StatusNoContent)
}
// GetCores godoc
// @Summary Get available browser cores
// @Description Get the list of browser cores available in the Browser Gateway container
// @Tags browser-contexts
// @Produce json
// @Success 200 {object} BrowserCoresResponse
// @Failure 502 {object} ErrorResponse
// @Router /browser-contexts/cores [get].
func (h *BrowserContextsHandler) GetCores(c echo.Context) error {
url := fmt.Sprintf("%s/cores/", h.browserGatewayURL)
req, err := http.NewRequestWithContext(c.Request().Context(), http.MethodGet, url, nil)
if err != nil {
return echo.NewHTTPError(http.StatusBadGateway, "failed to create request")
}
resp, err := http.DefaultClient.Do(req) //nolint:gosec // URL is from trusted internal config
if err != nil {
h.logger.Warn("browser gateway unreachable", slog.String("error", err.Error()))
return c.JSON(http.StatusOK, BrowserCoresResponse{Cores: []string{"chromium"}})
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
if err != nil {
return echo.NewHTTPError(http.StatusBadGateway, "failed to read browser gateway response")
}
var result BrowserCoresResponse
if err := json.Unmarshal(body, &result); err != nil {
return echo.NewHTTPError(http.StatusBadGateway, "failed to parse browser gateway response")
}
return c.JSON(http.StatusOK, result)
}
// BrowserCoresResponse is the response for the GetCores endpoint.
type BrowserCoresResponse struct {
Cores []string `json:"cores"`
}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+29
View File
@@ -611,6 +611,10 @@ export type HandlersBatchDeleteRequest = {
ids?: Array<string>;
};
export type HandlersBrowserCoresResponse = {
cores?: Array<string>;
};
export type HandlersChannelMeta = {
capabilities?: ChannelChannelCapabilities;
config_schema?: ChannelConfigSchema;
@@ -5944,6 +5948,31 @@ export type PostBrowserContextsResponses = {
export type PostBrowserContextsResponse = PostBrowserContextsResponses[keyof PostBrowserContextsResponses];
export type GetBrowserContextsCoresData = {
body?: never;
path?: never;
query?: never;
url: '/browser-contexts/cores';
};
export type GetBrowserContextsCoresErrors = {
/**
* Bad Gateway
*/
502: HandlersErrorResponse;
};
export type GetBrowserContextsCoresError = GetBrowserContextsCoresErrors[keyof GetBrowserContextsCoresErrors];
export type GetBrowserContextsCoresResponses = {
/**
* OK
*/
200: HandlersBrowserCoresResponse;
};
export type GetBrowserContextsCoresResponse = GetBrowserContextsCoresResponses[keyof GetBrowserContextsCoresResponses];
export type DeleteBrowserContextsByIdData = {
body?: never;
path: {
+21 -1
View File
@@ -99,6 +99,7 @@ WORKSPACE="$WORKSPACE_DEFAULT"
MEMOH_DATA_DIR="$MEMOH_DATA_DIR_DEFAULT"
USE_CN_MIRROR="${USE_CN_MIRROR:-false}"
USE_SPARSE="${USE_SPARSE:-false}"
BROWSER_CORES="${BROWSER_CORES:-chromium,firefox}"
if [ "$SILENT" = false ]; then
echo "Configure Memoh (press Enter to use defaults):" > /dev/tty
@@ -148,6 +149,19 @@ if [ "$SILENT" = false ]; then
y|Y|yes|YES) USE_SPARSE=true ;;
esac
echo "" > /dev/tty
echo " Browser core selection:" > /dev/tty
echo " 1) Chromium only (smaller image)" > /dev/tty
echo " 2) Firefox only" > /dev/tty
echo " 3) Both Chromium and Firefox (default)" > /dev/tty
printf " Browser core [3]: " > /dev/tty
read -r input < /dev/tty || true
case "$input" in
1) BROWSER_CORES="chromium" ;;
2) BROWSER_CORES="firefox" ;;
*) BROWSER_CORES="chromium,firefox" ;;
esac
echo "" > /dev/tty
fi
@@ -222,11 +236,17 @@ fi
echo POSTGRES_PASSWORD="${PG_PASS}" >> .env
echo MEMOH_CONFIG=./config.toml >> .env
echo MEMOH_DATA_DIR="{$MEMOH_DATA_DIR}" >> .env
echo BROWSER_CORES="${BROWSER_CORES}" >> .env
echo USE_SPARSE="${USE_SPARSE}" >> .env
echo "${GREEN}✓ Browser cores: ${BROWSER_CORES}${NC}"
echo ""
echo "${GREEN}Pulling latest Docker images...${NC}"
$DOCKER compose $COMPOSE_FILES $COMPOSE_PROFILES pull
$DOCKER compose $COMPOSE_FILES $COMPOSE_PROFILES pull --ignore-buildable
echo ""
echo "${GREEN}Building browser image (cores: ${BROWSER_CORES})...${NC}"
$DOCKER compose $COMPOSE_FILES $COMPOSE_PROFILES build browser
echo ""
echo "${GREEN}Starting services (first startup may take a few minutes)...${NC}"
+37
View File
@@ -5482,6 +5482,32 @@ const docTemplate = `{
}
}
},
"/browser-contexts/cores": {
"get": {
"description": "Get the list of browser cores available in the Browser Gateway container",
"produces": [
"application/json"
],
"tags": [
"browser-contexts"
],
"summary": "Get available browser cores",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.BrowserCoresResponse"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/browser-contexts/{id}": {
"get": {
"description": "Get browser context by ID",
@@ -10039,6 +10065,17 @@ const docTemplate = `{
}
}
},
"handlers.BrowserCoresResponse": {
"type": "object",
"properties": {
"cores": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.ChannelMeta": {
"type": "object",
"properties": {
+37
View File
@@ -5473,6 +5473,32 @@
}
}
},
"/browser-contexts/cores": {
"get": {
"description": "Get the list of browser cores available in the Browser Gateway container",
"produces": [
"application/json"
],
"tags": [
"browser-contexts"
],
"summary": "Get available browser cores",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/handlers.BrowserCoresResponse"
}
},
"502": {
"description": "Bad Gateway",
"schema": {
"$ref": "#/definitions/handlers.ErrorResponse"
}
}
}
}
},
"/browser-contexts/{id}": {
"get": {
"description": "Get browser context by ID",
@@ -10030,6 +10056,17 @@
}
}
},
"handlers.BrowserCoresResponse": {
"type": "object",
"properties": {
"cores": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"handlers.ChannelMeta": {
"type": "object",
"properties": {
+25
View File
@@ -1001,6 +1001,13 @@ definitions:
type: string
type: array
type: object
handlers.BrowserCoresResponse:
properties:
cores:
items:
type: string
type: array
type: object
handlers.ChannelMeta:
properties:
capabilities:
@@ -6178,6 +6185,24 @@ paths:
summary: Update a browser context
tags:
- browser-contexts
/browser-contexts/cores:
get:
description: Get the list of browser cores available in the Browser Gateway
container
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/handlers.BrowserCoresResponse'
"502":
description: Bad Gateway
schema:
$ref: '#/definitions/handlers.ErrorResponse'
summary: Get available browser cores
tags:
- browser-contexts
/channels:
get:
description: List channel meta information including capabilities and schemas