npm package: @northsea4/extension-runtime
Description: Safe rule runtime for browser extensions (MV3-friendly)
Author: northsea4
Latest version: 1.1.0

# @northsea4/extension-runtime

一个面向浏览器扩展（尤其 MV3）的规则运行时。

- 默认使用安全表达式模式（`expr`）
- 支持沙盒 JS 模式（`js`，需宿主注入执行器）
- 提供统一 `run()` API、超时控制、策略校验与结果标准化

## 安装

```bash
pnpm add @northsea4/extension-runtime
```

## 快速开始

```ts
import { createRuntime } from '@northsea4/extension-runtime'

const runtime = createRuntime()

const result = await runtime.run(
  {
    id: 'rule-1',
    name: 'Upper Case',
    version: '1.0.0',
    mode: 'expr',
    code: 'upper(input.name)',
    helpers: ['upper']
  },
  {
    input: { name: 'clouddrive' },
    env: {},
    now: new Date().toISOString()
  }
)

if (result.ok) {
  console.log(result.output) // "CLOUDDRIVE"
}
```

也可以使用便捷调用（默认 `js` 模式）：

```ts
const result = await runtime.exec('return { upper: String(ctx.input.name).toUpperCase() }', {
  name: 'clouddrive'
})

// 明确指定模式也可以：
const exprResult = await runtime.execExpr(
  'upper(input.name)',
  { name: 'clouddrive' },
  { helpers: ['upper'] }
)
const jsResult = await runtime.execJs('return { ok: true, name: ctx.input.name }', {
  name: 'clouddrive'
})

// 透传 traceId（便于日志关联）
const traced = await runtime.exec('return { ok: true }', {}, { traceId: 'trace-20260318-001' })
console.log(traced.traceId) // trace-20260318-001

// 支持取消执行（AbortSignal）
const controller = new AbortController()
const task = runtime.exec('return { ok: true }', {}, { signal: controller.signal })
controller.abort()
const canceled = await task
console.log(canceled.error?.code) // CANCELED
```

## 两种模式

### `expr`（推荐默认）

- 适合大多数可配置规则
- 通过 AST 解释执行，不依赖 `eval/new Function`
- 安全边界更清晰，可控性更高

### `js`（高级兜底）

- 适合复杂自定义逻辑
- 必须在 `createRuntime` 时传入 `sandboxExecutor`
- 运行时会做策略校验与超时约束

```ts
import { createRuntime } from '@northsea4/extension-runtime'

const runtime = createRuntime({
  sandboxExecutor: async ({ code, ctx }) => {
    // 在宿主侧实现 postMessage -> sandbox page 的执行链路
    return { code, input: ctx.input }
  },
  logger: console
})
```

## Helper

可通过 `registerHelper` 注册自定义 helper：

```ts
runtime.registerHelper('suffix', (v, s) => `${String(v)}${String(s)}`)
```

## 表达式工具（同步 API）

除了 `execExpr`（统一结果结构、异步）外，也提供同步工具，便于需要“同步判定/编译缓存”的场景：

```ts
import { compileExpr, evalExpr, parseExpression } from '@northsea4/extension-runtime'

const ast = parseExpression('input.count > 0')
const ok = evalExpr('input.count > 0', { count: 1 }) // true

const compiled = compileExpr<{ count: number }, boolean>('input.count > 10')
const pass = compiled({ count: 12 }) // true
```

## 输出结果

`run()` 返回统一结构：

- `ok`: 是否成功
- `output`: 成功结果
- `error`: 失败信息（含错误码）
- `traceId`: 本次执行链路 ID（可透传或自动生成）
- `metrics`: 执行耗时和模式信息

开启 `logger` 后，debug/warn 日志会带统一字段：

- `mode`
- `channel`
- `elapsedMs`
- `ruleId`
- `traceId`

常见错误码（节选）：

- `TIMEOUT`: 执行超时
- `CANCELED`: 执行被取消
- `PARSE_ERROR`: 表达式解析失败
- `POLICY_DENIED`: 策略或配置不允许
- `SERIALIZE_ERROR`: 消息或结果不可序列化
- `UNSUPPORTED_ENV`: 当前上下文不支持所需 API（如无 DOM / 无 runtime API）
- `SANDBOX_INIT_FAILED`: sandbox iframe 初始化失败
- `SANDBOX_UNAVAILABLE`: sandbox 目标窗口不可用
- `RELAY_UNAVAILABLE`: tab relay 通道不可用
- `OFFSCREEN_UNAVAILABLE`: offscreen 通道不可用

## 开发

```bash
pnpm --filter @northsea4/extension-runtime build
pnpm --filter @northsea4/extension-runtime test
```

## 发布文档

- [CHANGELOG](./CHANGELOG.md)
- [API Stability Policy](./docs/api-stability.md)

## 示例

- 原生扩展示例位于 `examples/browser-extension/`
- 先执行 `pnpm --filter @northsea4/extension-runtime build`
- 同步 vendor 文件（推荐）：

  ```bash
  pnpm --filter @northsea4/extension-runtime sync:browser-example
  ```

- 或手动同步：

  ```bash
  cp packages/extension-runtime/lib/playground.js packages/extension-runtime/examples/browser-extension/vendor/extension-runtime.js
  ```

- 打开 Chrome 扩展管理页（`chrome://extensions`），开启开发者模式
- 点击“加载已解压的扩展程序”，选择 `packages/extension-runtime/examples/browser-extension`
- 在扩展卡片里点击“详情” -> “扩展程序选项”，打开 options 页测试 `expr/js` 规则执行

## Extension 集成示例（更具体）

下面给 3 种常见接入方式。你可以按环境选一种，不需要全上。

### 最小文件结构

```text
src/
  entrypoints/
    popup/main.ts
    sandbox/main.ts
    content/main.ts
    background/main.ts
    offscreen/main.ts
```

### 场景 A：扩展页直连 sandbox（popup/options/content 有 DOM）

#### 1) sandbox 页面（执行端）

```ts
// src/entrypoints/sandbox/main.ts
import { registerHtmlSandboxHandler } from '@northsea4/extension-runtime/extension'

registerHtmlSandboxHandler({
  helpers: {
    concat: (a, b) => `${String(a)}${String(b)}`
  }
})
```

#### 2) popup/options 页面（调用端）

```ts
// src/entrypoints/popup/main.ts
import { createRuntime } from '@northsea4/extension-runtime'
import { createHtmlSandboxExecutor } from '@northsea4/extension-runtime/extension'
import Browser from 'webextension-polyfill'

const sandboxExecutor = createHtmlSandboxExecutor({
  sandboxUrl: Browser.runtime.getURL('sandbox.html'),
  iframeId: 'runtime-sandbox-frame',
  warmupOnCreate: true
})
// 也可手动控制预热时机（例如首屏空闲时）
// await sandboxExecutor.warmup()

const runtime = createRuntime({
  sandboxExecutor
})

const result = await runtime.execJs(
  "return { msg: concat(ctx.input.name, '-ok') }",
  { name: 'clouddrive' },
  {
    id: 'popup-js-rule',
    helpers: ['concat'],
    env: { source: 'popup' }
  }
)

console.log(result)
```

`createHtmlSandboxExecutor` 的 `targetOrigin` 默认自动推断：

- 普通网页 URL：使用精确 origin
- 扩展/opaque 场景（如 `chrome-extension://...` 的 sandbox）：自动回退为 `*`
- 如需降低首次 `execJs` 延迟，可开启 `warmupOnCreate` 或在业务空闲期调用 `warmup()`

### 场景 B：background 通过 tab relay（依赖 content script）

#### 1) content script 注册 relay 服务

```ts
// src/entrypoints/content/main.ts
import {
  createHtmlSandboxExecutor,
  registerTabRelaySandboxService
} from '@northsea4/extension-runtime/extension'
import Browser from 'webextension-polyfill'

const cleanup = await registerTabRelaySandboxService({
  execute: createHtmlSandboxExecutor({
    sandboxUrl: Browser.runtime.getURL('sandbox.html'),
    iframeId: 'runtime-sandbox-relay-frame'
  })
})

window.addEventListener('beforeunload', cleanup)
```

#### 2) background 使用 relay executor

```ts
// src/entrypoints/background/main.ts
import { createRuntime } from '@northsea4/extension-runtime'
import { createTabRelaySandboxExecutor } from '@northsea4/extension-runtime/extension'

const runtime = createRuntime({
  sandboxExecutor: createTabRelaySandboxExecutor({
    targetTabId: 123,
    retry: {
      maxRetries: 1,
      retryDelayMs: 50
    }
  })
})

const result = await runtime.exec(
  "return { from: 'relay', upper: String(ctx.input.name).toUpperCase() }",
  { name: 'clouddrive' },
  {
    id: 'bg-relay-js-rule',
    env: { source: 'background' }
  }
)

console.log(result)
```

### 场景 C：background 走 offscreen（不依赖 tab）

> 说明：`offscreen` 主要是 Chromium 能力，跨浏览器项目建议加降级方案（例如回退到 tab relay）。

#### 1) offscreen 页面注册处理器

```ts
// src/entrypoints/offscreen/main.ts
import { registerOffscreenSandboxHandler } from '@northsea4/extension-runtime/extension'
import { createHtmlSandboxExecutor } from '@northsea4/extension-runtime/extension'
import Browser from 'webextension-polyfill'

registerOffscreenSandboxHandler({
  execute: createHtmlSandboxExecutor({
    sandboxUrl: Browser.runtime.getURL('sandbox.html'),
    iframeId: 'runtime-offscreen-sandbox-frame'
  })
})
```

#### 2) background 调用 offscreen executor

```ts
// src/entrypoints/background/main.ts
import { createRuntime } from '@northsea4/extension-runtime'
import { createOffscreenSandboxExecutor } from '@northsea4/extension-runtime/extension'

const runtime = createRuntime({
  sandboxExecutor: createOffscreenSandboxExecutor({
    documentPath: 'offscreen.html',
    reason: 'DOM_PARSER',
    justification: 'Execute extension-runtime js rules',
    retry: {
      maxRetries: 1,
      retryDelayMs: 50
    }
  })
})

const result = await runtime.exec(
  "return { from: 'offscreen', total: Number(ctx.input.a) + Number(ctx.input.b) }",
  { a: 2, b: 3 },
  {
    id: 'bg-offscreen-js-rule',
    env: { source: 'background' }
  }
)

console.log(result)
```

## Extension API 列表

`@northsea4/extension-runtime/extension` 提供：

- `createHtmlSandboxExecutor`
- `registerHtmlSandboxHandler`
- `createTabRelaySandboxExecutor`
- `registerTabRelaySandboxService`
- `createOffscreenSandboxExecutor`
- `registerOffscreenSandboxHandler`