Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,10 @@ declared in `contributes.hooks` with a role:
| `afterComplete` | File finalized on disk | Side effects: notify, post-process, hand off |
| `onError` | A task failed | Inspect `error.code`/`error.message`, log or notify |

Post-hooks receive `ctx.delivery`: its `id` remains stable across retries of
the same delivery, while `ctx.invocationId` identifies the current attempt.
Plugins can use the stable id for idempotent external side effects.

Roles order execution across plugins within a hook:
`resolve` → `enrich` → `post-process` → `audit`. Two are category-gated:
`resolve` requires the `site-resolver` category, `post-process` requires
Expand Down
3 changes: 3 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ Hook 就是下载的生命周期。代码里实现的每个 hook 都要同时在
| `afterComplete` | 文件已落盘 | 副作用:通知、后处理、交接 |
| `onError` | 任务失败 | 读取 `error.code`/`error.message`,记日志或通知 |

Post-hook 会收到 `ctx.delivery`:同一次投递发生重试时,`id` 保持稳定;
`ctx.invocationId` 则标识当前这一次尝试。插件可以用稳定 id 保证外部副作用幂等。

Role 决定同一 hook 上多个插件的执行顺序:
`resolve` → `enrich` → `post-process` → `audit`。其中两个与 category
挂钩:`resolve` 要求 `site-resolver` category,`post-process` 要求
Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.4/schema.json",
"$schema": "https://biomejs.dev/schemas/2.5.11/schema.json",
"vcs": {
"enabled": true,
"clientKind": "git",
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.13.0",
"packageManager": "pnpm@11.22.0",
"scripts": {
"build": "pnpm -r build",
"typecheck": "pnpm -r typecheck",
Expand All @@ -14,6 +14,6 @@
"check:facade": "node scripts/check-facade.mjs"
},
"devDependencies": {
"@biomejs/biome": "^2.5.4"
"@biomejs/biome": "^2.5.11"
}
}
2 changes: 1 addition & 1 deletion packages/create-motrix-plugin/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "create-motrix-plugin",
"version": "2.0.0",
"version": "2.0.1",
"description": "Scaffold a new Motrix plugin project — `pnpm create motrix-plugin`",
"type": "module",
"bin": "bin/create.mjs",
Expand Down
18 changes: 18 additions & 0 deletions packages/plugin-api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ hooks.beforeCreate(async (ctx) => {
})
```

## Stable post-hook delivery identity

Since 2.1, `afterComplete` and `onError` receive a durable delivery envelope:

```ts
hooks.afterComplete(async (ctx) => {
log.info('download finalized', {
deliveryId: ctx.delivery.id,
occurrenceId: ctx.delivery.occurrenceId,
filePath: ctx.filePath,
})
})
```

`ctx.delivery.id` remains stable when Motrix retries the same delivery, while
`ctx.invocationId` identifies the individual attempt. Retry counters, lease
state, and scheduler diagnostics are intentionally not exposed to plugins.

Make sure `src/virtual-module.d.ts` is visible to the TypeScript compiler.
Installing this package is enough — its `files` entry ships the `.d.ts`
alongside `dist`, and TypeScript picks up ambient module declarations from
Expand Down
10 changes: 5 additions & 5 deletions packages/plugin-api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@motrix/plugin-api",
"version": "2.0.0",
"version": "2.1.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand All @@ -18,13 +18,13 @@
},
"license": "MIT",
"scripts": {
"build": "tsup",
"build": "tsup && tsc -p tsconfig.build.json && node scripts/finalize-types.mjs",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"tsup": "^8",
"typescript": "^5.6",
"vitest": "^3"
"tsup": "^8.5.1",
"typescript": "^7.0.2",
"vitest": "^4.1.11"
}
}
9 changes: 9 additions & 0 deletions packages/plugin-api/scripts/finalize-types.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { readFile, writeFile } from 'node:fs/promises'

const declarationPath = new URL('../dist/index.d.ts', import.meta.url)
const reference = '/// <reference path="../src/virtual-module.d.ts" />\n'
const declaration = await readFile(declarationPath, 'utf8')

if (!declaration.startsWith(reference)) {
await writeFile(declarationPath, `${reference}${declaration}`, 'utf8')
}
32 changes: 32 additions & 0 deletions packages/plugin-api/src/virtual-module.contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type {
AfterCompleteContext,
DeliveryEnvelopeV1,
OnErrorContext,
} from 'motrix:plugin-api'

type Equal<Left, Right> =
(<Value>() => Value extends Left ? 1 : 2) extends <
Value,
>() => Value extends Right ? 1 : 2
? true
: false

type Assert<Condition extends true> = Condition

type AfterCompleteHandler = Parameters<
typeof import('motrix:plugin-api').hooks.afterComplete
>[0]
type OnErrorHandler = Parameters<
typeof import('motrix:plugin-api').hooks.onError
>[0]

type _AfterCompleteContext = Assert<
Equal<Parameters<AfterCompleteHandler>[0], AfterCompleteContext>
>
type _OnErrorContext = Assert<
Equal<Parameters<OnErrorHandler>[0], OnErrorContext>
>
type _StableDeliveryId = Assert<Equal<DeliveryEnvelopeV1['id'], string>>
type _NoRetryAttemptLeak = Assert<
Equal<'attempt' extends keyof DeliveryEnvelopeV1 ? true : false, false>
>
76 changes: 69 additions & 7 deletions packages/plugin-api/src/virtual-module.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ declare module 'motrix:plugin-api' {
| { [k: string]: JsonValue }

export interface HookCtxBase {
readonly schemaVersion: 1
readonly invocationId: string
readonly taskId: string
readonly sourceUrl: string
readonly createdBy: 'user' | 'protocol' | 'api'
readonly requestedAt: number
Expand Down Expand Up @@ -35,21 +38,80 @@ declare module 'motrix:plugin-api' {
}

export interface BeforeFinalizeContext extends HookCtxBase {
readonly task: { id: string; filePath: string; saveDir: string }
readonly task: PluginTaskSnapshotV1
readonly inputFilePath: string
readonly filePath: string
readonly targetFilePath: string
update(patch: Partial<{ filePath: string }>): void
}

export interface AfterCompleteContext {
readonly task: { id: string; filePath: string; saveDir: string }
export interface ErrorDescriptorV1 {
readonly code: string
readonly message: string
readonly detailKey: string | null
readonly detailParams: Readonly<Record<string, string>> | null
}

export interface PluginTaskSnapshotV1 {
readonly schemaVersion: 1
readonly id: string
readonly name: string
readonly type: 'http' | 'ftp' | 'bt' | 'magnet' | 'metalink'
readonly kind: 'direct' | 'bt' | 'hls' | 'mux'
readonly status:
| 'queued'
| 'fetching_metadata'
| 'metadata_ready'
| 'downloading'
| 'finalizing'
| 'seeding'
| 'paused'
| 'completed'
| 'error'
| 'removed'
readonly filePath: string
readonly metadata: ReadonlyPluginMetadata
readonly saveDir: string
readonly filename: string
readonly progress: number
readonly totalBytes: number
readonly downloadedBytes: number
readonly uploadedBytes: number
readonly sizeWhenDone: number
readonly fileCount: number
readonly createdAt: number
readonly updatedAt: number
readonly finishedAt: number | null
readonly category: string | null
readonly infoHash: string | null
readonly error: ErrorDescriptorV1 | null
}

export interface DeliveryEnvelopeV1 {
readonly schemaVersion: 1
/** Stable across retries of the same plugin delivery. */
readonly id: string
/** Identifies the task occurrence that created this delivery. */
readonly occurrenceId: string
/** Unix timestamp in milliseconds for the source occurrence. */
readonly occurredAt: number
}

export interface OnErrorContext {
readonly task: { id: string; filePath: string; saveDir: string }
readonly error: { code: string; message: string }
export interface PostHookContextBase {
readonly schemaVersion: 1
/** Fresh for every delivery attempt. */
readonly invocationId: string
readonly taskId: string
readonly task: PluginTaskSnapshotV1
readonly filePath: string
readonly delivery: DeliveryEnvelopeV1
readonly metadata: ReadonlyPluginMetadata
readonly signal: AbortSignal
}

export interface AfterCompleteContext extends PostHookContextBase {}

export interface OnErrorContext extends PostHookContextBase {
readonly error: ErrorDescriptorV1
}

export interface PluginMetadata {
Expand Down
9 changes: 9 additions & 0 deletions packages/plugin-api/tsconfig.build.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist"
},
"exclude": ["src/virtual-module.contract.ts"]
}
1 change: 0 additions & 1 deletion packages/plugin-api/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ import { defineConfig } from 'tsup'
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
dts: { banner: '/// <reference path="../src/virtual-module.d.ts" />' },
clean: true,
})
18 changes: 9 additions & 9 deletions packages/plugin-cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@motrix/plugin-cli",
"version": "2.1.0",
"version": "2.1.1",
"type": "module",
"bin": {
"motrix-plugin": "dist/bin/motrix-plugin.js",
Expand All @@ -24,17 +24,17 @@
"test": "vitest run"
},
"dependencies": {
"chokidar": "^4",
"commander": "^12",
"esbuild": "^0.24",
"yazl": "^2"
"chokidar": "^5.0.0",
"commander": "^15.0.0",
"esbuild": "^0.28.2",
"yazl": "^3.3.1"
},
"devDependencies": {
"@motrix/plugin-manifest-schema": "workspace:*",
"@types/node": "^24",
"tsup": "^8",
"typescript": "^5.6",
"vitest": "^3",
"zod": "^4"
"tsup": "^8.5.1",
"typescript": "^7.0.2",
"vitest": "^4.1.11",
"zod": "^4.5.4"
}
}
2 changes: 1 addition & 1 deletion packages/plugin-cli/src/bin/motrix-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { validateHostPermissions } from '../commands/validate-host-permissions'
const program = new Command()
.name('motrix-plugin')
.description('Motrix plugin developer tools')
.version('2.1.0')
.version('2.1.1')

program
.command('init <name>')
Expand Down
10 changes: 9 additions & 1 deletion packages/plugin-cli/src/commands/pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const ENTRY_MODE = 0o100644
interface YazlAddFileOptions {
mtime?: Date
mode?: number
forceDosTimestamp?: boolean
}

interface YazlZipFile {
Expand Down Expand Up @@ -152,7 +153,14 @@ export async function pack(opts: PackOptions): Promise<PackResult> {
yazl as { ZipFile: new () => YazlZipFile }
).ZipFile()
for (const { abs, rel } of entries) {
z.addFile(abs, rel, { mtime: DOS_EPOCH, mode: ENTRY_MODE })
z.addFile(abs, rel, {
mtime: DOS_EPOCH,
mode: ENTRY_MODE,
// yazl >=3.3 adds an absolute Unix timestamp field by default. Its
// bytes vary with the timezone used to construct DOS_EPOCH, while the
// required DOS fields below are deliberately local-time normalized.
forceDosTimestamp: true,
})
}
z.end()
await new Promise<void>((res, rej) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"dev": "motrix-plugin dev"
},
"devDependencies": {
"@motrix/plugin-api": "^2.0.0",
"@motrix/plugin-cli": "^2.0.0",
"esbuild": "^0.24",
"typescript": "^5.6"
"@motrix/plugin-api": "^2.1.0",
"@motrix/plugin-cli": "^2.1.1",
"esbuild": "^0.28.2",
"typescript": "^7.0.2"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"dev": "motrix-plugin dev"
},
"devDependencies": {
"@motrix/plugin-api": "^2.0.0",
"@motrix/plugin-cli": "^2.0.0",
"esbuild": "^0.24",
"typescript": "^5.6"
"@motrix/plugin-api": "^2.1.0",
"@motrix/plugin-cli": "^2.1.1",
"esbuild": "^0.28.2",
"typescript": "^7.0.2"
}
}
22 changes: 22 additions & 0 deletions packages/plugin-cli/tests/init.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,28 @@ describe('motrix-plugin init', () => {
expect(tplFiles).toHaveLength(0)
})

it.each(['basic-resolver', 'post-action'] as const)(
'%s: pins the current SDK and build-tool generations',
async (template) => {
const result = await init({
projectName: `dependency-${template}`,
template,
destDir,
publisher: 'alice',
})
const generatedPackage = JSON.parse(
readFileSync(path.join(result.created, 'package.json'), 'utf8')
)

expect(generatedPackage.devDependencies).toEqual({
'@motrix/plugin-api': '^2.1.0',
'@motrix/plugin-cli': '^2.1.1',
esbuild: '^0.28.2',
typescript: '^7.0.2',
})
}
)

it('template substitution: motrix-plugin.json has correct id and no unresolved vars', async () => {
const result = await init({
projectName: 'demo',
Expand Down
1 change: 1 addition & 0 deletions packages/plugin-cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "DOM"],
"types": ["node"],
"strict": true,
"declaration": true,
"esModuleInterop": true,
Expand Down
Loading