@stacksjs/browser-extension 0.75.408 → 0.75.417
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build.d.ts +3 -3
- package/dist/build.js +2 -3
- package/dist/bundle.d.ts +8 -0
- package/dist/bundle.js +2 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -1
- package/dist/package.d.ts +2 -2
- package/dist/types.d.ts +27 -4
- package/dist/webkit-build.d.ts +48 -0
- package/dist/webkit-build.js +2 -0
- package/dist/webkit-scripts.d.ts +69 -0
- package/dist/webkit-scripts.js +88 -0
- package/dist/webkit.d.ts +178 -0
- package/dist/webkit.js +1 -0
- package/package.json +2 -2
package/dist/build.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import type { BuildOptions,
|
|
1
|
+
import type { BuildOptions, BuildTarget, ExtensionConfig } from './types';
|
|
2
2
|
/** Resolve the output directory for a target. */
|
|
3
|
-
export declare function resolveOutdir(config: ExtensionConfig, target:
|
|
3
|
+
export declare function resolveOutdir(config: ExtensionConfig, target: BuildTarget, override?: string): string;
|
|
4
4
|
/** Rewrite promise-style `chrome.*` API access in `code` to `browser.*`. */
|
|
5
5
|
export declare function rewriteBrowserNamespace(code: string): { code: string, replacements: number };
|
|
6
6
|
/**`) + extra `assets` copied in
|
|
@@ -13,6 +13,6 @@ export declare function rewriteBrowserNamespace(code: string): { code: string, r
|
|
|
13
13
|
*
|
|
14
14
|
* Replaces the hand-rolled `build-extension.ts` every extension used to carry.
|
|
15
15
|
*/
|
|
16
|
-
export declare function buildExtension(config: ExtensionConfig, options: BuildOptions): Promise<{ outdir: string, target:
|
|
16
|
+
export declare function buildExtension(config: ExtensionConfig, options: BuildOptions): Promise<{ outdir: string, target: BuildTarget }>;
|
|
17
17
|
/** Build every configured target. */
|
|
18
18
|
export declare function buildAllTargets(config: ExtensionConfig, options: Omit<BuildOptions, 'target'>): Promise<void>;
|
package/dist/build.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import{Glob}from"bun";import{cp,mkdir,rm}from"node:fs/promises";import{existsSync}from"node:fs";import{join,resolve}from"node:path";import{contentScriptOut,generateManifest}from"./manifest";import{sanitizeExtensionHtml}from"./sanitize";export function resolveOutdir(config,target,override){if(override)return override;if(typeof config.outdir==="string")return config.outdir;if(config.outdir?.[target])return config.outdir[target];return target==="chrome"?"dist":`dist-${target}`}const browserApiNamespaces=["runtime","tabs","declarativeNetRequest","storage","action","alarms","scripting","webNavigation","cookies","contextMenus","notifications","i18n","downloads","permissions","windows","bookmarks","history","search","sidePanel","omnibox","idle","management","commands","sessions","topSites","extension","types","devtools","offscreen","clipboard"],browserNamespacePattern=new RegExp(`\\bchrome\\.(?=(?:${browserApiNamespaces.join("|")})[?.])`,"g");export function rewriteBrowserNamespace(code){const replacements=(code.match(browserNamespacePattern)??[]).length;if(!replacements)return{code,replacements:0};return{code:code.replace(browserNamespacePattern,"browser."),replacements}}function normalizePage(page){return typeof page==="string"?{template:page}:page}function pageEntries(pages){if(!pages)return[];const out=[];if(pages.popup)out.push({name:"popup",page:normalizePage(pages.popup)});if(pages.options)out.push({name:"options",page:normalizePage(pages.options)});for(const[name,page]of Object.entries(pages.extra??{}))out.push({name,page:normalizePage(page)});return out}async function buildScript(entry,out,outdir,cwd,minify){const
|
|
2
|
-
`)}`);const
|
|
3
|
-
`)}`);for(const{name,page}of pages){const file=join(outdir,`${name}.html`);if(!existsSync(file))continue;const own=page.script?[`${name}.js`]:[];await Bun.write(file,sanitizeExtensionHtml(await Bun.file(file).text(),own))}for await(const chunk of new Glob("chunk-*.js").scan(outdir))await rm(join(outdir,chunk),{force:!0})}const scripts=[];if(config.background)scripts.push({entry:config.background,out:"background.js"});for(const cs of config.content??[])scripts.push({entry:cs.entry,out:contentScriptOut(cs.entry,cs.out)});await Promise.all(scripts.map((s)=>buildScript(s.entry,s.out,outdir,cwd,minify)));if(target==="safari")for await(const file of new Glob("*.js").scan(outdir)){const path=join(outdir,file),{code,replacements}=rewriteBrowserNamespace(await Bun.file(path).text());if(replacements)await Bun.write(path,code)}for(const rule of config.rules??[]){if(!rule.source)continue;const mod=await import(resolve(cwd,rule.source)),build=mod.default??mod.buildRules??mod.rules??Object.values(mod).find((v)=>typeof v==="function")??Object.values(mod).find((v)=>Array.isArray(v)),data=typeof build==="function"?await build():build;await Bun.write(join(outdir,rule.path??`rules/${rule.id}.json`),`${JSON.stringify(data,null,2)}
|
|
1
|
+
import{Glob}from"bun";import{cp,mkdir,rm}from"node:fs/promises";import{existsSync}from"node:fs";import{join,resolve}from"node:path";import{bundleScript,loadModuleValue}from"./bundle";import{contentScriptOut,generateManifest}from"./manifest";import{sanitizeExtensionHtml}from"./sanitize";import{buildWebKit}from"./webkit-build";export function resolveOutdir(config,target,override){if(override)return override;if(typeof config.outdir==="string")return config.outdir;if(config.outdir?.[target])return config.outdir[target];return target==="chrome"?"dist":`dist-${target}`}const browserApiNamespaces=["runtime","tabs","declarativeNetRequest","storage","action","alarms","scripting","webNavigation","cookies","contextMenus","notifications","i18n","downloads","permissions","windows","bookmarks","history","search","sidePanel","omnibox","idle","management","commands","sessions","topSites","extension","types","devtools","offscreen","clipboard"],browserNamespacePattern=new RegExp(`\\bchrome\\.(?=(?:${browserApiNamespaces.join("|")})[?.])`,"g");export function rewriteBrowserNamespace(code){const replacements=(code.match(browserNamespacePattern)??[]).length;if(!replacements)return{code,replacements:0};return{code:code.replace(browserNamespacePattern,"browser."),replacements}}function normalizePage(page){return typeof page==="string"?{template:page}:page}function pageEntries(pages){if(!pages)return[];const out=[];if(pages.popup)out.push({name:"popup",page:normalizePage(pages.popup)});if(pages.options)out.push({name:"options",page:normalizePage(pages.options)});for(const[name,page]of Object.entries(pages.extra??{}))out.push({name,page:normalizePage(page)});return out}async function buildScript(entry,out,outdir,cwd,minify){await Bun.write(join(outdir,out),await bundleScript(entry,cwd,minify))}export async function buildExtension(config,options){const target=options.target??"chrome",cwd=options.cwd??process.cwd(),outdir=resolve(cwd,resolveOutdir(config,target,options.outdir)),minify=options.minify??!0;if(target==="webkit"){await buildWebKit(config,{outdir,cwd,version:options.version,minify});await config.hooks?.postBuild?.({config,target,outdir,version:options.version,cwd});return{outdir,target}}await rm(outdir,{recursive:!0,force:!0});await mkdir(join(outdir,"rules"),{recursive:!0});await mkdir(join(outdir,"icons"),{recursive:!0});if(config.public){const pub=resolve(cwd,config.public);if(existsSync(pub))await cp(pub,outdir,{recursive:!0})}for(const[dest,src]of Object.entries(config.assets??{})){const s=resolve(cwd,src);if(existsSync(s))await Bun.write(join(outdir,dest),Bun.file(s))}const pages=pageEntries(config.pages);await Promise.all(pages.filter((p)=>p.page.script).map((p)=>buildScript(p.page.script,`${p.name}.js`,outdir,cwd,minify)));if(pages.length){const{default:stxPlugin}=await import("bun-plugin-stx"),resourcesPartials=resolve(cwd,"resources/partials"),legacyPartials=resolve(cwd,"partials"),partialsDir=existsSync(resourcesPartials)?resourcesPartials:legacyPartials,result=await Bun.build({entrypoints:pages.map((p)=>resolve(cwd,p.page.template)),outdir,minify,naming:{entry:"[name].html"},plugins:[stxPlugin({partialsDir})]});if(!result.success)throw Error(`[browser-extension] failed to build pages: ${result.logs.join(`
|
|
2
|
+
`)}`);for(const{name,page}of pages){const file=join(outdir,`${name}.html`);if(!existsSync(file))continue;const own=page.script?[`${name}.js`]:[];await Bun.write(file,sanitizeExtensionHtml(await Bun.file(file).text(),own))}for await(const chunk of new Glob("chunk-*.js").scan(outdir))await rm(join(outdir,chunk),{force:!0})}const scripts=[];if(config.background)scripts.push({entry:config.background,out:"background.js"});for(const cs of config.content??[])scripts.push({entry:cs.entry,out:contentScriptOut(cs.entry,cs.out)});await Promise.all(scripts.map((s)=>buildScript(s.entry,s.out,outdir,cwd,minify)));if(target==="safari")for await(const file of new Glob("*.js").scan(outdir)){const path=join(outdir,file),{code,replacements}=rewriteBrowserNamespace(await Bun.file(path).text());if(replacements)await Bun.write(path,code)}for(const rule of config.rules??[]){if(!rule.source)continue;const data=await loadModuleValue(cwd,rule.source);await Bun.write(join(outdir,rule.path??`rules/${rule.id}.json`),`${JSON.stringify(data,null,2)}
|
|
4
3
|
`)}await Bun.write(join(outdir,"manifest.json"),`${JSON.stringify(generateManifest(config,{version:options.version,target}),null,2)}
|
|
5
4
|
`);await config.hooks?.postBuild?.({config,target,outdir,version:options.version,cwd});return{outdir,target}}export async function buildAllTargets(config,options){for(const target of config.targets??["chrome","firefox"])await buildExtension(config,{...options,target})}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Bundle one entrypoint to a classic IIFE script (content scripts + page scripts). */
|
|
2
|
+
export declare function bundleScript(entry: string, cwd: string, minify: boolean): Promise<string>;
|
|
3
|
+
/**
|
|
4
|
+
* Load what a rules module produces. `path#name` reads that export; a bare
|
|
5
|
+
* path reads its default export, `buildRules` or `rules`, else its sole
|
|
6
|
+
* exported function or array. A function is called (and awaited).
|
|
7
|
+
*/
|
|
8
|
+
export declare function loadModuleValue<T = unknown>(cwd: string, source: string): Promise<T>;
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{resolve}from"node:path";export async function bundleScript(entry,cwd,minify){const result=await Bun.build({entrypoints:[resolve(cwd,entry)],target:"browser",format:"iife",splitting:!1,minify});if(!result.success)throw Error(`[browser-extension] failed to build ${entry}: ${result.logs.join(`
|
|
2
|
+
`)}`);const output=result.outputs[0];if(!output)throw Error(`[browser-extension] build produced no output for ${entry}`);return output.text()}export async function loadModuleValue(cwd,source){const[path,name]=source.split("#"),mod=await import(resolve(cwd,path));if(name!==void 0&&!(name in mod))throw Error(`[browser-extension] ${path} has no export named "${name}"`);const build=name!==void 0?mod[name]:mod.default??mod.buildRules??mod.rules??Object.values(mod).find((v)=>typeof v==="function")??Object.values(mod).find((v)=>Array.isArray(v));return typeof build==="function"?await build():build}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export*from"./build";export*from"./app-store-connect";export*from"./app-store-submission";export*from"./chrome-web-store";export*from"./config";export*from"./firefox-addons";export*from"./firefox-previews";export*from"./manifest";export*from"./package";export*from"./publish-plan";export*from"./safari";export*from"./scaffold";export*from"./sanitize";export*from"./types";
|
|
1
|
+
export*from"./build";export*from"./app-store-connect";export*from"./app-store-submission";export*from"./chrome-web-store";export*from"./config";export*from"./firefox-addons";export*from"./firefox-previews";export*from"./manifest";export*from"./package";export*from"./publish-plan";export*from"./safari";export*from"./scaffold";export*from"./sanitize";export*from"./types";export*from"./webkit";export*from"./webkit-build";export*from"./webkit-scripts";
|
package/dist/package.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { BuildTarget, ExtensionConfig } from './types';
|
|
2
2
|
/**
|
|
3
3
|
* Package a built extension into a store-ready `.zip` (one per target). Builds
|
|
4
4
|
* first unless `build: false`. Uses the system `zip` (available on CI runners
|
|
@@ -7,7 +7,7 @@ import type { ExtensionConfig, ExtensionTarget } from './types';
|
|
|
7
7
|
*/
|
|
8
8
|
export declare function packageExtension(config: ExtensionConfig, options: PackageOptions): Promise<string>;
|
|
9
9
|
export declare interface PackageOptions {
|
|
10
|
-
target?:
|
|
10
|
+
target?: BuildTarget
|
|
11
11
|
version: string
|
|
12
12
|
cwd?: string
|
|
13
13
|
outfile?: string
|
package/dist/types.d.ts
CHANGED
|
@@ -44,10 +44,25 @@ export declare interface ManifestOverrides {
|
|
|
44
44
|
webAccessibleResources?: Array<{ resources: string[], matches: string[] }>
|
|
45
45
|
extra?: Record<string, unknown>
|
|
46
46
|
}
|
|
47
|
+
/** A module whose default export (or sole exported function/value) produces rules. */
|
|
48
|
+
export declare interface WebKitRuleSource {
|
|
49
|
+
id: string
|
|
50
|
+
source: string
|
|
51
|
+
enabled?: boolean
|
|
52
|
+
}
|
|
53
|
+
/** The `webkit` build target: `dist-webkit/` for apps that embed WKWebView. */
|
|
54
|
+
export declare interface WebKitOptions {
|
|
55
|
+
maxRulesPerList?: number
|
|
56
|
+
dynamicRules?: WebKitRuleSource[]
|
|
57
|
+
cosmetic?: WebKitRuleSource[]
|
|
58
|
+
selectorsPerRule?: number
|
|
59
|
+
messageHandler?: string
|
|
60
|
+
excludeScripts?: string[]
|
|
61
|
+
}
|
|
47
62
|
/** Context passed to build hooks. */
|
|
48
63
|
export declare interface BuildContext {
|
|
49
64
|
config: ExtensionConfig
|
|
50
|
-
target:
|
|
65
|
+
target: BuildTarget
|
|
51
66
|
outdir: string
|
|
52
67
|
version: string
|
|
53
68
|
cwd: string
|
|
@@ -141,7 +156,8 @@ export declare interface ExtensionConfig {
|
|
|
141
156
|
safariAppCategory?: string
|
|
142
157
|
safariAppStore?: SafariAppStoreConfig
|
|
143
158
|
safariExclude?: string[]
|
|
144
|
-
targets?:
|
|
159
|
+
targets?: BuildTarget[]
|
|
160
|
+
webkit?: WebKitOptions
|
|
145
161
|
background?: string
|
|
146
162
|
content?: ContentScript[]
|
|
147
163
|
pages?: ExtensionPages
|
|
@@ -151,13 +167,13 @@ export declare interface ExtensionConfig {
|
|
|
151
167
|
assets?: Record<string, string>
|
|
152
168
|
rules?: RuleResource[]
|
|
153
169
|
manifest?: ManifestOverrides
|
|
154
|
-
outdir?: Partial<Record<
|
|
170
|
+
outdir?: Partial<Record<BuildTarget, string>> | string
|
|
155
171
|
hooks?: {
|
|
156
172
|
postBuild?: (ctx: BuildContext) => void | Promise<void>
|
|
157
173
|
}
|
|
158
174
|
}
|
|
159
175
|
export declare interface BuildOptions {
|
|
160
|
-
target?:
|
|
176
|
+
target?: BuildTarget
|
|
161
177
|
version: string
|
|
162
178
|
outdir?: string
|
|
163
179
|
minify?: boolean
|
|
@@ -171,7 +187,14 @@ export declare interface BuildOptions {
|
|
|
171
187
|
* manifest, build graph, and packaging from it — no hand-written manifest.json
|
|
172
188
|
* or per-project build script.
|
|
173
189
|
*/
|
|
190
|
+
/** The browsers an extension ships to through a store. */
|
|
174
191
|
export type ExtensionTarget = 'chrome' | 'firefox' | 'safari';
|
|
192
|
+
/**
|
|
193
|
+
* Everything `buddy extension:build` can produce: the store targets, plus
|
|
194
|
+
* `webkit` - the extension's rules and content scripts as WebKit
|
|
195
|
+
* content-blocker lists and `WKUserScript`s, for apps that embed WKWebView.
|
|
196
|
+
*/
|
|
197
|
+
export type BuildTarget = ExtensionTarget | 'webkit';
|
|
175
198
|
export type SafariPlatform = 'macos' | 'ios';
|
|
176
199
|
export type SafariScreenshotDisplayType = 'APP_DESKTOP' | 'APP_IPHONE_67' | 'APP_IPAD_PRO_3GEN_129';
|
|
177
200
|
export type SafariAppStoreReleaseType = 'MANUAL' | 'AFTER_APPROVAL' | 'SCHEDULED';
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionConfig } from './types';
|
|
2
|
+
import type { WebKitConversionReport } from './webkit';
|
|
3
|
+
import type { WebKitUserScript } from './webkit-scripts';
|
|
4
|
+
/** `Very Good AdBlock` → `very-good-adblock`. */
|
|
5
|
+
export declare function webkitSlug(name: string): string;
|
|
6
|
+
/**
|
|
7
|
+
* Build the `webkit` target into `outdir`:
|
|
8
|
+
*
|
|
9
|
+
* - `lists/<id>.json` - the static rulesets, `webkit.dynamicRules` and
|
|
10
|
+
* `webkit.cosmetic` as WebKit content-blocker lists (see `buildWebKitLists`)
|
|
11
|
+
* - `scripts/<id>.js` - every content script as a standalone `WKUserScript`
|
|
12
|
+
* source, isolated-world ones bridged to `window.webkit.messageHandlers`
|
|
13
|
+
* - `manifest.json` - the lists (id, content-hash version, group, file), the
|
|
14
|
+
* scripts (matches, run time, world, file) and the conversion report
|
|
15
|
+
*
|
|
16
|
+
* Rules WebKit cannot express are counted in `report.unsupported` and printed,
|
|
17
|
+
* never dropped silently.
|
|
18
|
+
*/
|
|
19
|
+
export declare function buildWebKit(config: ExtensionConfig, options: { outdir: string, cwd: string, version: string, minify?: boolean }): Promise<WebKitManifest>;
|
|
20
|
+
/** One compiled-list entry in `dist-webkit/manifest.json`. */
|
|
21
|
+
export declare interface WebKitManifestList {
|
|
22
|
+
id: string
|
|
23
|
+
version: string
|
|
24
|
+
group: string
|
|
25
|
+
kind: 'network' | 'cosmetic'
|
|
26
|
+
enabled: boolean
|
|
27
|
+
ruleCount: number
|
|
28
|
+
file: string
|
|
29
|
+
}
|
|
30
|
+
/** One user-script entry in `dist-webkit/manifest.json`; the source lives in `file`. */
|
|
31
|
+
export declare interface WebKitManifestScript extends Omit<WebKitUserScript, 'source'> {
|
|
32
|
+
file: string
|
|
33
|
+
bridged: boolean
|
|
34
|
+
apis: string[]
|
|
35
|
+
unsupportedApis: string[]
|
|
36
|
+
}
|
|
37
|
+
/** `dist-webkit/manifest.json`. */
|
|
38
|
+
export declare interface WebKitManifest {
|
|
39
|
+
format: 'stacks-webkit'
|
|
40
|
+
formatVersion: 1
|
|
41
|
+
name: string
|
|
42
|
+
version: string
|
|
43
|
+
maxRulesPerList: number
|
|
44
|
+
bridge: { messageHandler: string, version: number }
|
|
45
|
+
lists: WebKitManifestList[]
|
|
46
|
+
scripts: WebKitManifestScript[]
|
|
47
|
+
report: WebKitConversionReport
|
|
48
|
+
}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
import{existsSync}from"node:fs";import{mkdir,rm}from"node:fs/promises";import{join,resolve}from"node:path";import{bundleScript,loadModuleValue}from"./bundle";import{contentScriptOut}from"./manifest";import{buildWebKitLists}from"./webkit";import{WEBKIT_BRIDGE_VERSION,wrapForWebKit}from"./webkit-scripts";export function webkitSlug(name){return name.toLowerCase().replace(/[^a-z0-9]+/g,"-").replace(/(^-|-$)/g,"")}async function rulesetRules(rule,config,cwd){if(rule.source)return asArray(await loadModuleValue(cwd,rule.source),rule.id);const file=resolve(cwd,config.public??"public",rule.path??`rules/${rule.id}.json`);if(!existsSync(file))throw Error(`[browser-extension] ruleset "${rule.id}" has no source module and no file at ${file}`);return asArray(await Bun.file(file).json(),rule.id)}function asArray(value,id){if(!Array.isArray(value))throw Error(`[browser-extension] "${id}" did not produce an array of rules`);return value}export async function buildWebKit(config,options){const{outdir,cwd}=options,webkit=config.webkit??{},maxRulesPerList=webkit.maxRulesPerList??50000,messageHandler=webkit.messageHandler??webkitSlug(config.name);await rm(outdir,{recursive:!0,force:!0});await mkdir(join(outdir,"lists"),{recursive:!0});await mkdir(join(outdir,"scripts"),{recursive:!0});const groups=[];for(const rule of config.rules??[])groups.push({id:rule.id,enabled:rule.enabled??!0,dnr:await rulesetRules(rule,config,cwd)});for(const source of webkit.dynamicRules??[])groups.push({id:source.id,enabled:source.enabled??!0,dnr:asArray(await loadModuleValue(cwd,source.source),source.id)});for(const source of webkit.cosmetic??[])groups.push({id:source.id,enabled:source.enabled??!0,cosmetic:asArray(await loadModuleValue(cwd,source.source),source.id)});const{lists,report}=buildWebKitLists(groups,{maxRulesPerList,selectorsPerRule:webkit.selectorsPerRule}),manifestLists=[];for(const list of lists){const file=`lists/${list.id}.json`;await Bun.write(join(outdir,file),JSON.stringify(list.rules));manifestLists.push({id:list.id,version:list.version,group:list.group,kind:list.kind,enabled:list.enabled,ruleCount:list.rules.length,file})}const excluded=new Set(webkit.excludeScripts??[]),scripts=[];for(const cs of config.content??[]){const id=contentScriptOut(cs.entry,cs.out).replace(/\.js$/,"");if(excluded.has(id))continue;const world=cs.world==="MAIN"?"page":"isolated",wrapped=wrapForWebKit(await bundleScript(cs.entry,cwd,options.minify??!0),{handler:messageHandler,world}),file=`scripts/${id}.js`;await Bun.write(join(outdir,file),wrapped.source);scripts.push({id,matches:cs.matches,excludeMatches:cs.excludeMatches??[],runAt:cs.runAt==="document_start"?"document-start":"document-end",allFrames:cs.allFrames??!1,matchAboutBlank:cs.matchAboutBlank??!1,world,file,bridged:wrapped.bridged,apis:wrapped.apis,unsupportedApis:wrapped.unsupportedApis})}const manifest={format:"stacks-webkit",formatVersion:1,name:config.name,version:options.version,maxRulesPerList,bridge:{messageHandler,version:WEBKIT_BRIDGE_VERSION},lists:manifestLists,scripts,report};await Bun.write(join(outdir,"manifest.json"),`${JSON.stringify(manifest,null,2)}
|
|
2
|
+
`);logWebKitReport(manifest);return manifest}function logWebKitReport(manifest){const rules=manifest.lists.reduce((sum,list)=>sum+list.ruleCount,0);console.log(`[browser-extension] webkit: ${manifest.lists.length} list(s), ${rules} rule(s), ${manifest.scripts.length} script(s)`);for(const issue of manifest.report.unsupported)console.warn(`[browser-extension] webkit: ${issue.count} rule(s) left out - ${issue.reason} (e.g. ${issue.examples.slice(0,3).join(", ")})`);for(const issue of manifest.report.approximated)console.warn(`[browser-extension] webkit: ${issue.count} rule(s) approximated - ${issue.reason}`);for(const script of manifest.scripts)if(script.world==="isolated"&&script.unsupportedApis.length)console.warn(`[browser-extension] webkit: script ${script.id} calls ${script.unsupportedApis.join(", ")}, which the WebKit bridge does not provide`)}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Every `chrome.<namespace>.<member>` (or `browser.…`) a bundle touches. */
|
|
2
|
+
export declare function chromeApisUsed(code: string): string[];
|
|
3
|
+
/**
|
|
4
|
+
* The bridge as a JavaScript expression evaluating to a `chrome`-shaped
|
|
5
|
+
* object. One instance per handler per content world, shared by every script
|
|
6
|
+
* that world runs, so listeners registered by one script see every event.
|
|
7
|
+
*/
|
|
8
|
+
export declare function webkitBridgeSource(handler: string): string;
|
|
9
|
+
/**
|
|
10
|
+
* Prepare one bundled content script for WebKit. Page-world scripts ship as
|
|
11
|
+
* they are. Isolated-world scripts that touch `chrome.*` / `browser.*` get the
|
|
12
|
+
* bridge as those bindings.
|
|
13
|
+
*/
|
|
14
|
+
export declare function wrapForWebKit(code: string, options: { handler: string, world: 'page' | 'isolated' }): WebKitScriptApis & { source: string };
|
|
15
|
+
/** The `chrome.*` surface the bridge provides. */
|
|
16
|
+
export declare const WEBKIT_BRIDGE_APIS: readonly string[];
|
|
17
|
+
/** The version of the message contract the bridge speaks. */
|
|
18
|
+
export declare const WEBKIT_BRIDGE_VERSION: 1;
|
|
19
|
+
/**
|
|
20
|
+
* Content scripts as standalone `WKUserScript` sources.
|
|
21
|
+
*
|
|
22
|
+
* A WebKit app has no `chrome.*`. A page-world script never had it either, so
|
|
23
|
+
* it ships as bundled. An isolated-world script that calls `chrome.runtime` or
|
|
24
|
+
* `chrome.storage` is wrapped with a small bridge that turns those calls into
|
|
25
|
+
* messages to a `window.webkit.messageHandlers.<name>` handler the host app
|
|
26
|
+
* installs. Nothing global is defined for the page: the bridge is passed into
|
|
27
|
+
* the bundle as its `chrome` and `browser` bindings.
|
|
28
|
+
*
|
|
29
|
+
* The message contract (version 1), page to app, through
|
|
30
|
+
* `webkit.messageHandlers[name].postMessage(body)` (register the handler with
|
|
31
|
+
* `WKScriptMessageHandlerWithReply` so `postMessage` resolves to the reply):
|
|
32
|
+
*
|
|
33
|
+
* | `body.api` | `body.payload` | reply |
|
|
34
|
+
* |-----------------------|-----------------------------------------|-------------------------------------|
|
|
35
|
+
* | `runtime.sendMessage` | `{ message }` | the response, or null |
|
|
36
|
+
* | `storage.get` | `{ area, keys: null \| string \| string[] \| object }` | an object of the stored items |
|
|
37
|
+
* | `storage.set` | `{ area, items }` | null |
|
|
38
|
+
* | `storage.remove` | `{ area, keys: string[] }` | null |
|
|
39
|
+
* | `storage.clear` | `{ area }` | null |
|
|
40
|
+
*
|
|
41
|
+
* Every body also carries `v: 1`. `area` is `local`, `sync` or `session`. A
|
|
42
|
+
* rejected reply (an error from the handler) rejects the call, and callback
|
|
43
|
+
* callers see it as `chrome.runtime.lastError`.
|
|
44
|
+
*
|
|
45
|
+
* App to page, by evaluating JavaScript in the script's content world
|
|
46
|
+
* (`callAsyncJavaScript` awaits the returned promise):
|
|
47
|
+
*
|
|
48
|
+
* ```js
|
|
49
|
+
* __stacksWebKit[name].dispatch({ type: 'runtime.message', message, sender }) // resolves to the first listener's response
|
|
50
|
+
* __stacksWebKit[name].dispatch({ type: 'storage.changed', area, changes }) // changes: { key: { oldValue, newValue } }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
/** A content script ready for `WKUserScript`. */
|
|
54
|
+
export declare interface WebKitUserScript {
|
|
55
|
+
id: string
|
|
56
|
+
matches: string[]
|
|
57
|
+
excludeMatches: string[]
|
|
58
|
+
runAt: 'document-start' | 'document-end'
|
|
59
|
+
allFrames: boolean
|
|
60
|
+
matchAboutBlank: boolean
|
|
61
|
+
world: 'page' | 'isolated'
|
|
62
|
+
source: string
|
|
63
|
+
}
|
|
64
|
+
/** What a wrapped script calls, and whether the bridge covers it. */
|
|
65
|
+
export declare interface WebKitScriptApis {
|
|
66
|
+
apis: string[]
|
|
67
|
+
unsupportedApis: string[]
|
|
68
|
+
bridged: boolean
|
|
69
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const WEBKIT_BRIDGE_APIS=["runtime.sendMessage","runtime.onMessage","runtime.id","runtime.lastError","storage.local","storage.sync","storage.session","storage.onChanged"],WEBKIT_BRIDGE_VERSION=1;const apiUse=/(?<![\w$.])(?:chrome|browser)\s*(?:\?\.|\.)\s*([A-Za-z]+)\s*(?:\?\.|\.)\s*([A-Za-z]+)/g;export function chromeApisUsed(code){const found=new Set;for(const match of code.matchAll(apiUse))found.add(`${match[1]}.${match[2]}`);return[...found].sort()}export function webkitBridgeSource(handler){return`(function (name) {
|
|
2
|
+
var root = typeof globalThis !== 'undefined' ? globalThis : window
|
|
3
|
+
var registry = root.__stacksWebKit || (root.__stacksWebKit = {})
|
|
4
|
+
if (registry[name]) return registry[name].chrome
|
|
5
|
+
var runtime = { id: name, lastError: undefined }
|
|
6
|
+
function post(api, payload) {
|
|
7
|
+
var handlers = root.webkit && root.webkit.messageHandlers
|
|
8
|
+
var target = handlers && handlers[name]
|
|
9
|
+
if (!target) return Promise.reject(new Error('Stacks WebKit bridge: no "' + name + '" message handler is installed'))
|
|
10
|
+
try { return Promise.resolve(target.postMessage({ v: ${WEBKIT_BRIDGE_VERSION}, api: api, payload: payload })) }
|
|
11
|
+
catch (error) { return Promise.reject(error) }
|
|
12
|
+
}
|
|
13
|
+
function settle(promise, callback) {
|
|
14
|
+
if (typeof callback !== 'function') return promise
|
|
15
|
+
promise.then(function (value) { callback(value) }, function (error) {
|
|
16
|
+
runtime.lastError = { message: String((error && error.message) || error) }
|
|
17
|
+
try { callback(undefined) } finally { runtime.lastError = undefined }
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
function nothing() { return undefined }
|
|
21
|
+
function event(listeners) {
|
|
22
|
+
return {
|
|
23
|
+
addListener: function (fn) { if (listeners.indexOf(fn) < 0) listeners.push(fn) },
|
|
24
|
+
removeListener: function (fn) { var i = listeners.indexOf(fn); if (i >= 0) listeners.splice(i, 1) },
|
|
25
|
+
hasListener: function (fn) { return listeners.indexOf(fn) >= 0 },
|
|
26
|
+
hasListeners: function () { return listeners.length > 0 }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
var messageListeners = []
|
|
30
|
+
var changeListeners = []
|
|
31
|
+
var areaListeners = { local: [], sync: [], session: [] }
|
|
32
|
+
function area(areaName) {
|
|
33
|
+
return {
|
|
34
|
+
get: function (keys, callback) {
|
|
35
|
+
if (typeof keys === 'function') { callback = keys; keys = undefined }
|
|
36
|
+
var request = keys === undefined ? null : keys
|
|
37
|
+
return settle(post('storage.get', { area: areaName, keys: request }).then(function (items) {
|
|
38
|
+
items = items || {}
|
|
39
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) return items
|
|
40
|
+
var merged = {}
|
|
41
|
+
for (var key in request) merged[key] = Object.prototype.hasOwnProperty.call(items, key) ? items[key] : request[key]
|
|
42
|
+
return merged
|
|
43
|
+
}), callback)
|
|
44
|
+
},
|
|
45
|
+
set: function (items, callback) { return settle(post('storage.set', { area: areaName, items: items }).then(nothing), callback) },
|
|
46
|
+
remove: function (keys, callback) { return settle(post('storage.remove', { area: areaName, keys: typeof keys === 'string' ? [keys] : keys }).then(nothing), callback) },
|
|
47
|
+
clear: function (callback) { return settle(post('storage.clear', { area: areaName }).then(nothing), callback) },
|
|
48
|
+
onChanged: event(areaListeners[areaName])
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
runtime.sendMessage = function () {
|
|
52
|
+
var args = Array.prototype.slice.call(arguments)
|
|
53
|
+
var callback = typeof args[args.length - 1] === 'function' ? args.pop() : undefined
|
|
54
|
+
var message = typeof args[0] === 'string' && args.length > 1 ? args[1] : args[0]
|
|
55
|
+
return settle(post('runtime.sendMessage', { message: message }), callback)
|
|
56
|
+
}
|
|
57
|
+
runtime.onMessage = event(messageListeners)
|
|
58
|
+
var chrome = { runtime: runtime, storage: { local: area('local'), sync: area('sync'), session: area('session'), onChanged: event(changeListeners) } }
|
|
59
|
+
function each(listeners, args) {
|
|
60
|
+
listeners.slice().forEach(function (fn) { try { fn.apply(null, args) } catch (error) { console.error(error) } })
|
|
61
|
+
}
|
|
62
|
+
function dispatch(event) {
|
|
63
|
+
if (!event || typeof event !== 'object') return undefined
|
|
64
|
+
if (event.type === 'storage.changed') {
|
|
65
|
+
var areaName = event.area || 'local'
|
|
66
|
+
each(changeListeners, [event.changes || {}, areaName])
|
|
67
|
+
each(areaListeners[areaName] || [], [event.changes || {}])
|
|
68
|
+
return undefined
|
|
69
|
+
}
|
|
70
|
+
if (event.type !== 'runtime.message') return undefined
|
|
71
|
+
return new Promise(function (resolve) {
|
|
72
|
+
var waiting = 0
|
|
73
|
+
var done = false
|
|
74
|
+
function respond(value) { if (!done) { done = true; resolve(value) } }
|
|
75
|
+
messageListeners.slice().forEach(function (fn) {
|
|
76
|
+
var result
|
|
77
|
+
try { result = fn(event.message, event.sender || { id: name }, respond) }
|
|
78
|
+
catch (error) { console.error(error); return }
|
|
79
|
+
if (result === true) waiting++
|
|
80
|
+
else if (result && typeof result.then === 'function') { waiting++; result.then(respond, function () { respond(undefined) }) }
|
|
81
|
+
})
|
|
82
|
+
if (!waiting) respond(undefined)
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
registry[name] = { chrome: chrome, dispatch: dispatch, version: ${WEBKIT_BRIDGE_VERSION} }
|
|
86
|
+
return chrome
|
|
87
|
+
})(${JSON.stringify(handler)})`}export function wrapForWebKit(code,options){const apis=chromeApisUsed(code),unsupportedApis=apis.filter((api)=>!WEBKIT_BRIDGE_APIS.includes(api));if(options.world==="page"||!apis.length)return{source:code,apis,unsupportedApis:options.world==="page"?apis:[],bridged:!1};const bridge=webkitBridgeSource(options.handler);return{source:["(function (chrome) {","var browser = chrome;",code.trim(),`})(${bridge});`,""].join(`
|
|
88
|
+
`),apis,unsupportedApis,bridged:!0}}
|
package/dist/webkit.d.ts
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A DNR `urlFilter` as the regular expression a WebKit `url-filter` takes.
|
|
3
|
+
*
|
|
4
|
+
* `||` anchors at a host label, `|` at the start or end of the URL, `*` is any
|
|
5
|
+
* run of characters and `^` a separator (or, at the end, the end of the URL).
|
|
6
|
+
* Everything else is literal. WebKit has no alternation, so a trailing `^` is
|
|
7
|
+
* written `(sep.*)?$`: a separator then anything, or nothing at all.
|
|
8
|
+
*/
|
|
9
|
+
export declare function urlFilterToWebKitRegex(filter: string): string;
|
|
10
|
+
/** The regex a WebKit `url-filter` uses to match one host and its subdomains. */
|
|
11
|
+
export declare function hostToWebKitRegex(host: string): string;
|
|
12
|
+
/** Whether `pattern` stays inside WebKit's content-blocker regex subset. */
|
|
13
|
+
export declare function isWebKitRegex(pattern: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* A DNR `regexFilter` as one or more WebKit `url-filter` patterns. Alternation
|
|
16
|
+
* becomes several patterns (each its own rule); anything outside WebKit's
|
|
17
|
+
* subset throws `WebKitUnsupportedError`.
|
|
18
|
+
*/
|
|
19
|
+
export declare function regexFilterToWebKit(regex: string): string[];
|
|
20
|
+
/**
|
|
21
|
+
* One DNR rule as WebKit rules. Throws `WebKitUnsupportedError` when WebKit
|
|
22
|
+
* has no way to say it; otherwise lists any way the result matches differently.
|
|
23
|
+
*/
|
|
24
|
+
export declare function convertDnrRule(rule: DnrRule): DnrRuleConversion;
|
|
25
|
+
/** Why a cosmetic selector cannot ship, or null when it can. */
|
|
26
|
+
export declare function cosmeticSelectorProblem(selector: string): string | null;
|
|
27
|
+
/** A content hash: the list's `version`. */
|
|
28
|
+
export declare function webkitListVersion(rules: WebKitRule[]): string;
|
|
29
|
+
/**
|
|
30
|
+
* Convert rule groups to WebKit lists.
|
|
31
|
+
*
|
|
32
|
+
* Within a list, WebKit applies `ignore-previous-rules` only to rules before
|
|
33
|
+
* it, so each list is ordered by DNR priority and then DNR's precedence
|
|
34
|
+
* (allow beats block at one priority). Lists compile independently, while DNR
|
|
35
|
+
* evaluates every enabled ruleset together, so each network list repeats every
|
|
36
|
+
* exception from the enabled groups (plus its own group's). A group over
|
|
37
|
+
* `maxRulesPerList` is split into `<id>`, `<id>-2`, … with the exceptions
|
|
38
|
+
* counted against each part. Cosmetic groups never receive network exceptions:
|
|
39
|
+
* an extension's DNR `allow` does not unhide elements.
|
|
40
|
+
*/
|
|
41
|
+
export declare function buildWebKitLists(groups: WebKitRuleGroup[], options?: WebKitListOptions): { lists: WebKitRuleList[], report: WebKitConversionReport };
|
|
42
|
+
/** WebKit's hard limit on rules in one compiled list. */
|
|
43
|
+
export declare const WEBKIT_MAX_RULES_PER_LIST: unknown;
|
|
44
|
+
/** The parts of a `chrome.declarativeNetRequest.RuleCondition` this converter reads. */
|
|
45
|
+
export declare interface DnrCondition {
|
|
46
|
+
urlFilter?: string
|
|
47
|
+
regexFilter?: string
|
|
48
|
+
isUrlFilterCaseSensitive?: boolean
|
|
49
|
+
initiatorDomains?: string[]
|
|
50
|
+
excludedInitiatorDomains?: string[]
|
|
51
|
+
domains?: string[]
|
|
52
|
+
excludedDomains?: string[]
|
|
53
|
+
requestDomains?: string[]
|
|
54
|
+
excludedRequestDomains?: string[]
|
|
55
|
+
resourceTypes?: DnrResourceType[] | string[]
|
|
56
|
+
excludedResourceTypes?: DnrResourceType[] | string[]
|
|
57
|
+
requestMethods?: string[]
|
|
58
|
+
excludedRequestMethods?: string[]
|
|
59
|
+
domainType?: 'firstParty' | 'thirdParty'
|
|
60
|
+
tabIds?: number[]
|
|
61
|
+
excludedTabIds?: number[]
|
|
62
|
+
responseHeaders?: unknown[]
|
|
63
|
+
excludedResponseHeaders?: unknown[]
|
|
64
|
+
}
|
|
65
|
+
/** A `chrome.declarativeNetRequest.Rule`, loosely typed so `@types/chrome` rules assign to it. */
|
|
66
|
+
export declare interface DnrRule {
|
|
67
|
+
id: number
|
|
68
|
+
priority?: number
|
|
69
|
+
action: { type: DnrActionType | string, [key: string]: unknown }
|
|
70
|
+
condition: DnrCondition
|
|
71
|
+
}
|
|
72
|
+
export declare interface WebKitTrigger {
|
|
73
|
+
'url-filter': string
|
|
74
|
+
'url-filter-is-case-sensitive'?: boolean
|
|
75
|
+
'resource-type'?: WebKitResourceType[]
|
|
76
|
+
'load-type'?: Array<'first-party' | 'third-party'>
|
|
77
|
+
'load-context'?: Array<'top-frame' | 'child-frame'>
|
|
78
|
+
'if-domain'?: string[]
|
|
79
|
+
'unless-domain'?: string[]
|
|
80
|
+
}
|
|
81
|
+
/** One rule in WebKit's content-blocker JSON (`WKContentRuleListStore`). */
|
|
82
|
+
export declare interface WebKitRule {
|
|
83
|
+
trigger: WebKitTrigger
|
|
84
|
+
action: WebKitAction
|
|
85
|
+
}
|
|
86
|
+
/** A cosmetic rule: elements matching `selector` are hidden (`css-display-none`). */
|
|
87
|
+
export declare interface WebKitCosmeticRule {
|
|
88
|
+
selector: string | string[]
|
|
89
|
+
domains?: string[]
|
|
90
|
+
excludedDomains?: string[]
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* A named source of rules: one DNR ruleset, the extension's dynamic rules, or
|
|
94
|
+
* a set of cosmetic selectors. Each group becomes one or more lists, so a host
|
|
95
|
+
* app can switch groups on and off the way the extension switches rulesets.
|
|
96
|
+
*/
|
|
97
|
+
export declare interface WebKitRuleGroup {
|
|
98
|
+
id: string
|
|
99
|
+
enabled?: boolean
|
|
100
|
+
dnr?: DnrRule[]
|
|
101
|
+
cosmetic?: WebKitCosmeticRule[]
|
|
102
|
+
}
|
|
103
|
+
export declare interface WebKitListOptions {
|
|
104
|
+
maxRulesPerList?: number
|
|
105
|
+
selectorsPerRule?: number
|
|
106
|
+
}
|
|
107
|
+
/** A WebKit content-blocker list, ready for `WKContentRuleListStore.compileContentRuleList`. */
|
|
108
|
+
export declare interface WebKitRuleList {
|
|
109
|
+
id: string
|
|
110
|
+
version: string
|
|
111
|
+
group: string
|
|
112
|
+
kind: 'network' | 'cosmetic'
|
|
113
|
+
enabled: boolean
|
|
114
|
+
rules: WebKitRule[]
|
|
115
|
+
}
|
|
116
|
+
export declare interface WebKitConversionIssue {
|
|
117
|
+
reason: string
|
|
118
|
+
count: number
|
|
119
|
+
examples: Array<number | string>
|
|
120
|
+
}
|
|
121
|
+
export declare interface WebKitConversionReport {
|
|
122
|
+
input: number
|
|
123
|
+
output: number
|
|
124
|
+
unsupported: WebKitConversionIssue[]
|
|
125
|
+
approximated: WebKitConversionIssue[]
|
|
126
|
+
}
|
|
127
|
+
/** A converted rule with what ordering it needs: DNR priority, then action precedence. */
|
|
128
|
+
export declare interface RankedWebKitRule {
|
|
129
|
+
rule: WebKitRule
|
|
130
|
+
priority: number
|
|
131
|
+
rank: number
|
|
132
|
+
exception: boolean
|
|
133
|
+
}
|
|
134
|
+
export declare interface DnrRuleConversion {
|
|
135
|
+
rules: RankedWebKitRule[]
|
|
136
|
+
approximated: string[]
|
|
137
|
+
}
|
|
138
|
+
export type DnrResourceType = | 'main_frame'
|
|
139
|
+
| 'sub_frame'
|
|
140
|
+
| 'stylesheet'
|
|
141
|
+
| 'script'
|
|
142
|
+
| 'image'
|
|
143
|
+
| 'font'
|
|
144
|
+
| 'object'
|
|
145
|
+
| 'xmlhttprequest'
|
|
146
|
+
| 'ping'
|
|
147
|
+
| 'csp_report'
|
|
148
|
+
| 'media'
|
|
149
|
+
| 'websocket'
|
|
150
|
+
| 'webtransport'
|
|
151
|
+
| 'webbundle'
|
|
152
|
+
| 'other';
|
|
153
|
+
export type DnrActionType = 'block' | 'allow' | 'allowAllRequests' | 'upgradeScheme' | 'redirect' | 'modifyHeaders';
|
|
154
|
+
export type WebKitResourceType = | 'document'
|
|
155
|
+
| 'image'
|
|
156
|
+
| 'style-sheet'
|
|
157
|
+
| 'script'
|
|
158
|
+
| 'font'
|
|
159
|
+
| 'raw'
|
|
160
|
+
| 'svg-document'
|
|
161
|
+
| 'media'
|
|
162
|
+
| 'popup'
|
|
163
|
+
| 'ping'
|
|
164
|
+
| 'fetch'
|
|
165
|
+
| 'websocket'
|
|
166
|
+
| 'other';
|
|
167
|
+
export type WebKitAction = | { type: 'block' }
|
|
168
|
+
| { type: 'ignore-previous-rules' }
|
|
169
|
+
| { type: 'make-https' }
|
|
170
|
+
| { type: 'css-display-none', selector: string }
|
|
171
|
+
/** Thrown for a rule (or pattern) WebKit's content-blocker format cannot express. */
|
|
172
|
+
export declare class WebKitUnsupportedError extends Error {
|
|
173
|
+
name: string;
|
|
174
|
+
}
|
|
175
|
+
declare class Issues {
|
|
176
|
+
add(reason: string, example: number | string): void;
|
|
177
|
+
list(): WebKitConversionIssue[];
|
|
178
|
+
}
|
package/dist/webkit.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{createHash}from"node:crypto";export const WEBKIT_MAX_RULES_PER_LIST=150000;export class WebKitUnsupportedError extends Error{name="WebKitUnsupportedError"}const ALL_DNR_TYPES=["main_frame","sub_frame","stylesheet","script","image","font","object","xmlhttprequest","ping","csp_report","media","websocket","webtransport","webbundle","other"],RESOURCE_TYPE_MAP={stylesheet:"style-sheet",script:"script",image:"image",font:"font",object:"other",xmlhttprequest:"fetch",ping:"ping",csp_report:"ping",media:"media",websocket:"websocket",other:"other"},ALL_WEBKIT_SUBRESOURCE_TYPES=["style-sheet","script","image","font","fetch","ping","media","websocket","other"],SEPARATOR="[^a-zA-Z0-9_.%-]",HOST_ANCHOR="^[a-z][a-z0-9+.-]*://([^/:]*\\.)?",MAX_REPEAT=16,MAX_EXPANSIONS=64;function escapeRegexChar(char){return/[\\.+?$(){}[\]|*^]/.test(char)?`\\${char}`:char}function escapeRegex(text){return[...text].map(escapeRegexChar).join("")}function assertAscii(text,what){if(/[^\x00-\x7F]/.test(text))throw new WebKitUnsupportedError(`non-ASCII characters in ${what}`)}export function urlFilterToWebKitRegex(filter){assertAscii(filter,"urlFilter");let pattern=filter,prefix="",suffix="";if(pattern.startsWith("||")){prefix=HOST_ANCHOR;pattern=pattern.slice(2)}else if(pattern.startsWith("|")){prefix="^";pattern=pattern.slice(1)}if(pattern.endsWith("|")){suffix="$";pattern=pattern.slice(0,-1)}if(prefix===HOST_ANCHOR&&!suffix&&/^[\w.-]+\^$/.test(pattern))return`${prefix}${escapeRegex(pattern.slice(0,-1))}[:/]`;let body="";for(let index=0;index<pattern.length;index++){const char=pattern[index];if(char==="*")body+=".*";else if(char==="^"){const last=index===pattern.length-1;body+=last?suffix?`(${SEPARATOR})?`:`(${SEPARATOR}.*)?$`:SEPARATOR}else body+=escapeRegexChar(char)}if(!prefix)body=body.replace(/^(?:\.\*)+/,"");if(!suffix)body=body.replace(/(?:\.\*)+$/,"");return`${prefix}${body}${suffix}`||".*"}export function hostToWebKitRegex(host){return`${HOST_ANCHOR}${escapeRegex(host)}[:/]`}export function isWebKitRegex(pattern){if(/[^\x00-\x7F]/.test(pattern))return!1;let inClass=!1;for(let index=0;index<pattern.length;index++){const char=pattern[index];if(char==="\\"){const next=pattern[index+1]??"";if(/[0-9a-zA-Z]/.test(next))return!1;index++;continue}if(inClass){if(char==="]")inClass=!1;continue}if(char==="["){inClass=!0;continue}if(char==="|"||char==="{"||char==="}")return!1;if(char==="("&&pattern[index+1]==="?")return!1}return!inClass}function peek(cursor){return cursor.source[cursor.index]}function product(left,right){const out=[];for(const a of left)for(const b of right)out.push(a+b);if(out.length>MAX_EXPANSIONS)throw new WebKitUnsupportedError(`regexFilter expands to more than ${MAX_EXPANSIONS} alternatives`);return out}function parseAlternation(cursor){const options=[...parseSequence(cursor)];while(peek(cursor)==="|"){cursor.index++;options.push(...parseSequence(cursor));if(options.length>MAX_EXPANSIONS)throw new WebKitUnsupportedError(`regexFilter expands to more than ${MAX_EXPANSIONS} alternatives`)}return options}function parseSequence(cursor){let options=[""];while(cursor.index<cursor.source.length&&peek(cursor)!=="|"&&peek(cursor)!==")"){const atom=parseAtom(cursor);options=product(options,applyQuantifier(cursor,atom))}return options}function parseClass(cursor){let out="[";cursor.index++;if(peek(cursor)==="^"){out+="^";cursor.index++}if(peek(cursor)==="]"){out+="\\]";cursor.index++}while(cursor.index<cursor.source.length&&peek(cursor)!=="]"){const char=peek(cursor);if(char==="\\"){const next=cursor.source[cursor.index+1]??"";cursor.index+=2;if(next==="d")out+="0-9";else if(next==="w")out+="a-zA-Z0-9_";else if(/[a-zA-Z0-9]/.test(next))throw new WebKitUnsupportedError(`regexFilter uses \\${next}, which WebKit does not support`);else out+=`\\${next}`;continue}if(char==="["&&cursor.source[cursor.index+1]===":")throw new WebKitUnsupportedError("regexFilter uses a POSIX character class");out+=char;cursor.index++}if(peek(cursor)!=="]")throw new WebKitUnsupportedError("regexFilter has an unterminated character class");cursor.index++;return`${out}]`}function parseAtom(cursor){const char=peek(cursor);if(char==="("){cursor.index++;if(peek(cursor)==="?")if(cursor.source[cursor.index+1]===":")cursor.index+=2;else throw new WebKitUnsupportedError("regexFilter uses a lookaround, named group or inline flag");const inner=parseAlternation(cursor);if(peek(cursor)!==")")throw new WebKitUnsupportedError("regexFilter has an unbalanced group");cursor.index++;return{options:inner.map((option)=>`(${option})`),quantifiable:!0}}if(char==="[")return{options:[parseClass(cursor)],quantifiable:!0};if(char==="\\"){const next=cursor.source[cursor.index+1]??"";cursor.index+=2;if(next==="d")return{options:["[0-9]"],quantifiable:!0};if(next==="w")return{options:["[a-zA-Z0-9_]"],quantifiable:!0};if(next==="/")return{options:["/"],quantifiable:!0};if(/[a-zA-Z0-9]/.test(next))throw new WebKitUnsupportedError(`regexFilter uses \\${next}, which WebKit does not support`);return{options:[`\\${next}`],quantifiable:!0}}if(char==="*"||char==="+"||char==="?"||char==="{")throw new WebKitUnsupportedError("regexFilter has a quantifier with nothing to repeat");cursor.index++;if(char==="^"||char==="$")return{options:[char],quantifiable:!1};return{options:[char==="}"||char==="]"?`\\${char}`:char],quantifiable:!0}}function applyQuantifier(cursor,atom){const char=peek(cursor);if(char!=="*"&&char!=="+"&&char!=="?"&&char!=="{")return atom.options;if(!atom.quantifiable)throw new WebKitUnsupportedError("regexFilter quantifies an anchor");let min,max;if(char==="{"){const match=/^\{(\d+)(,(\d*))?\}/.exec(cursor.source.slice(cursor.index));if(!match)throw new WebKitUnsupportedError("regexFilter has a malformed {n,m} repetition");cursor.index+=match[0].length;min=Number(match[1]);max=match[2]===void 0?min:match[3]?Number(match[3]):void 0;if(min>MAX_REPEAT||max!==void 0&&(max>MAX_REPEAT||max<min))throw new WebKitUnsupportedError(`regexFilter repeats more than ${MAX_REPEAT} times`)}else{cursor.index++;min=char==="+"?1:0;max=char==="?"?1:void 0}if(peek(cursor)==="?")cursor.index++;if(atom.options.length===1){const unit=atom.options[0];if(char!=="{")return[`${unit}${char}`];const required=unit.repeat(min);if(max===void 0)return[`${required}${unit}*`];return[`${required}${`${unit}?`.repeat(max-min)}`]}if(max===void 0)throw new WebKitUnsupportedError("regexFilter repeats an alternation without bound");let options=[];for(let count=min;count<=max;count++){let repeated=[""];for(let n=0;n<count;n++)repeated=product(repeated,atom.options);options.push(...repeated);if(options.length>MAX_EXPANSIONS)throw new WebKitUnsupportedError(`regexFilter expands to more than ${MAX_EXPANSIONS} alternatives`)}options=[...new Set(options)];return options}export function regexFilterToWebKit(regex){assertAscii(regex,"regexFilter");const cursor={source:regex,index:0},options=parseAlternation(cursor);if(cursor.index!==regex.length)throw new WebKitUnsupportedError("regexFilter has an unbalanced group");const patterns=[...new Set(options.map((option)=>option||".*"))];for(const pattern of patterns)if(!isWebKitRegex(pattern))throw new WebKitUnsupportedError("regexFilter cannot be expressed in WebKit's regex subset");return patterns}function normalizeDomain(domain){const bare=domain.trim().toLowerCase().replace(/^\*?\./,"");if(!/[^\x00-\x7F]/.test(bare))return bare;try{return new URL(`http://${bare}/`).hostname}catch{throw new WebKitUnsupportedError(`domain "${domain}" is not a valid host`)}}function isUnder(domain,parent){return domain===parent||domain.endsWith(`.${parent}`)}function domainCondition(included,excluded,what){let include=[...new Set(included.map(normalizeDomain))];const exclude=[...new Set(excluded.map(normalizeDomain))];if(include.length&&exclude.length){include=include.filter((domain)=>!exclude.some((ex)=>isUnder(domain,ex)));if(!include.length)return null;if(exclude.some((ex)=>include.some((domain)=>isUnder(ex,domain))))throw new WebKitUnsupportedError(`${what} excluded under an included domain`);return{"if-domain":include.map((domain)=>`*${domain}`)}}if(include.length)return{"if-domain":include.map((domain)=>`*${domain}`)};if(exclude.length)return{"unless-domain":exclude.map((domain)=>`*${domain}`)};return{}}function resourceVariants(condition,approximate){const listed=condition.resourceTypes??ALL_DNR_TYPES.filter((type)=>type!=="main_frame"),excluded=new Set(condition.excludedResourceTypes??[]),types=new Set(listed.filter((type)=>!excluded.has(type))),unmappable=[...types].filter((type)=>type!=="main_frame"&&type!=="sub_frame"&&!RESOURCE_TYPE_MAP[type]);if(unmappable.length&&unmappable.length===types.size)throw new WebKitUnsupportedError(`only resource types WebKit cannot match (${unmappable.join(", ")})`);if(unmappable.length&&condition.resourceTypes)approximate(`resource types without a WebKit equivalent ignored (${unmappable.sort().join(", ")})`);const sub=[...new Set([...types].map((type)=>RESOURCE_TYPE_MAP[type]).filter((type)=>Boolean(type)))],main=types.has("main_frame"),frame=types.has("sub_frame"),variants=[];if(main&&frame){if(ALL_WEBKIT_SUBRESOURCE_TYPES.every((type)=>sub.includes(type)))return[{}];sub.push("document","svg-document","popup")}if(sub.length)variants.push({types:sub});if(main&&!frame){variants.push({types:["document","svg-document"],loadContext:["top-frame"]});variants.push({types:["popup"]})}if(frame&&!main)variants.push({types:["document","svg-document"],loadContext:["child-frame"]});return variants}function hostOfFilter(filter){const match=/^\|\|([\w.-]+)(?:\^|\/)?\*?\|?$/.exec(filter);return match?match[1].toLowerCase():null}function leadingHostOfFilter(filter){const match=/^\|\|([\w.-]+)/.exec(filter);return match?match[1].toLowerCase():null}function urlPatterns(condition,approximate){const requestDomains=(condition.requestDomains??[]).map(normalizeDomain);if(condition.regexFilter!==void 0){if(requestDomains.length)throw new WebKitUnsupportedError("regexFilter combined with requestDomains");return regexFilterToWebKit(condition.regexFilter)}if(condition.urlFilter!==void 0&&condition.urlFilter!==""){const filter=condition.urlFilter,pattern=urlFilterToWebKitRegex(filter);if(!requestDomains.length)return[pattern];const leading=leadingHostOfFilter(filter);if(leading){if(requestDomains.some((domain)=>isUnder(leading,domain)))return[pattern];throw new WebKitUnsupportedError("a ||host urlFilter outside its requestDomains")}if(filter.startsWith("|"))throw new WebKitUnsupportedError("a |-anchored urlFilter combined with requestDomains");approximate("requestDomains with an unanchored urlFilter match it only after the host");return requestDomains.map((domain)=>`${HOST_ANCHOR}${escapeRegex(domain)}[:/].*${pattern}`)}if(requestDomains.length)return requestDomains.map(hostToWebKitRegex);return[".*"]}function convertAllowAllRequests(rule,approximated){const{condition}=rule,types=new Set(condition.resourceTypes??[]);if(!types.has("main_frame"))throw new WebKitUnsupportedError("allowAllRequests for subframes only");if(condition.regexFilter!==void 0)throw new WebKitUnsupportedError("allowAllRequests with a regexFilter");const pages=(condition.requestDomains??[]).map(normalizeDomain);if(condition.urlFilter){const host=hostOfFilter(condition.urlFilter);if(!host)throw new WebKitUnsupportedError("allowAllRequests with a urlFilter that is not a bare ||host");pages.push(host)}const initiators=[...condition.initiatorDomains??[],...condition.domains??[]],domains=domainCondition([...pages,...initiators],[...condition.excludedInitiatorDomains??[],...condition.excludedDomains??[]],"allowAllRequests page");if(domains===null)throw new WebKitUnsupportedError("every included domain is also excluded");if(types.has("sub_frame")&&(pages.length||initiators.length))approximated.push("allowAllRequests applies to top-level pages on its domains, not to their frames embedded elsewhere");return[{rule:{trigger:{"url-filter":".*",...domains},action:{type:"ignore-previous-rules"}},priority:rule.priority??1,rank:2,exception:!0}]}export function convertDnrRule(rule){const condition=rule.condition??{},approximated=[],approximate=(reason)=>{approximated.push(reason)};if(condition.tabIds?.length||condition.excludedTabIds?.length)throw new WebKitUnsupportedError("tabIds conditions");if(condition.requestMethods?.length||condition.excludedRequestMethods?.length)throw new WebKitUnsupportedError("request method conditions");if(condition.responseHeaders?.length||condition.excludedResponseHeaders?.length)throw new WebKitUnsupportedError("response header conditions");if(condition.excludedRequestDomains?.length)throw new WebKitUnsupportedError("excludedRequestDomains");const type=rule.action?.type;if(type==="allowAllRequests")return{rules:convertAllowAllRequests(rule,approximated),approximated};let action,rank;if(type==="block"){action={type:"block"};rank=1}else if(type==="allow"){action={type:"ignore-previous-rules"};rank=3}else if(type==="upgradeScheme"){action={type:"make-https"};rank=0}else if(type==="redirect")throw new WebKitUnsupportedError("redirect actions (content blockers cannot redirect)");else if(type==="modifyHeaders")throw new WebKitUnsupportedError("modifyHeaders actions");else throw new WebKitUnsupportedError(`unknown action type "${String(type)}"`);const domains=domainCondition([...condition.initiatorDomains??[],...condition.domains??[]],[...condition.excludedInitiatorDomains??[],...condition.excludedDomains??[]],"initiator domain");if(domains===null)throw new WebKitUnsupportedError("every included domain is also excluded");const patterns=urlPatterns(condition,approximate),variants=resourceVariants(condition,approximate),loadType=condition.domainType==="firstParty"?{"load-type":["first-party"]}:condition.domainType==="thirdParty"?{"load-type":["third-party"]}:{},caseSensitive=condition.isUrlFilterCaseSensitive?{"url-filter-is-case-sensitive":!0}:{},rules=[];for(const pattern of patterns)for(const variant of variants){const trigger={"url-filter":pattern,...caseSensitive,...variant.types?{"resource-type":variant.types}:{},...variant.loadContext?{"load-context":variant.loadContext}:{},...loadType,...domains};rules.push({rule:{trigger,action},priority:rule.priority??1,rank,exception:action.type==="ignore-previous-rules"})}return{rules,approximated}}const PROCEDURAL_PSEUDO=/:(?:-abp-|has-text|contains|matches-css|matches-attr|matches-path|xpath|upward|remove|style|min-text-length|watch-attr|others|if|if-not)\b/;export function cosmeticSelectorProblem(selector){const text=selector.trim();if(!text)return"an empty selector";if(/[{};]/.test(text))return"a selector containing CSS declarations";if(PROCEDURAL_PSEUDO.test(text))return"a procedural (adblock-only) pseudo-class";let depth=0,quote="";for(const char of text){if(quote){if(char===quote)quote="";continue}if(char==='"'||char==="'")quote=char;else if(char==="("||char==="[")depth++;else if(char===")"||char==="]")depth--;if(depth<0)return"a selector with unbalanced brackets"}if(depth!==0||quote)return"a selector with unbalanced brackets or quotes";return null}class Issues{byReason=new Map;add(reason,example){const issue=this.byReason.get(reason)??{reason,count:0,examples:[]};issue.count++;if(issue.examples.length<10)issue.examples.push(example);this.byReason.set(reason,issue)}list(){return[...this.byReason.values()].sort((a,b)=>b.count-a.count)}}export function webkitListVersion(rules){return createHash("sha256").update(JSON.stringify(rules)).digest("hex").slice(0,16)}function safeId(id){if(!/^[\w.-]+$/.test(id))throw Error(`[browser-extension] WebKit list id "${id}" may only contain letters, digits, '.', '_' and '-'`);return id}function chunk(items,size){const out=[];for(let index=0;index<items.length;index+=size)out.push(items.slice(index,index+size));return out}function byPrecedence(a,b){return a.priority-b.priority||a.rank-b.rank||a.order-b.order}export function buildWebKitLists(groups,options={}){const max=options.maxRulesPerList??50000;if(!Number.isInteger(max)||max<1||max>WEBKIT_MAX_RULES_PER_LIST)throw Error(`[browser-extension] maxRulesPerList must be an integer from 1 to ${WEBKIT_MAX_RULES_PER_LIST}`);const perRule=options.selectorsPerRule??100,unsupported=new Issues,approximated=new Issues;let input=0,output=0,order=0;const seen=new Set,converted=groups.map((group)=>{const id=safeId(group.id);if(seen.has(id))throw Error(`[browser-extension] two WebKit rule groups are both named "${id}"`);seen.add(id);const network=[];for(const rule of group.dnr??[]){input++;try{const result=convertDnrRule(rule);for(const reason of result.approximated)approximated.add(reason,rule.id);for(const ranked of result.rules)network.push({...ranked,order:order++});output+=result.rules.length}catch(error){if(!(error instanceof WebKitUnsupportedError))throw error;unsupported.add(error.message,rule.id)}}const cosmetic=[];for(const[index,entry]of(group.cosmetic??[]).entries()){input++;const example=`${id}#${index}`,selectors=(Array.isArray(entry.selector)?entry.selector:[entry.selector]).filter((selector)=>{const problem=cosmeticSelectorProblem(selector);if(problem)unsupported.add(`cosmetic: ${problem}`,example);return!problem});if(!selectors.length)continue;let domains;try{domains=domainCondition(entry.domains??[],entry.excludedDomains??[],"cosmetic page")}catch(error){if(!(error instanceof WebKitUnsupportedError))throw error;unsupported.add(`cosmetic: ${error.message}`,example);continue}if(domains===null){unsupported.add("cosmetic: every included domain is also excluded",example);continue}for(const part of chunk(selectors,perRule)){cosmetic.push({rule:{trigger:{"url-filter":".*",...domains},action:{type:"css-display-none",selector:part.join(", ")}},priority:0,rank:0,exception:!1,order:order++});output++}}return{id,enabled:group.enabled??!0,network,cosmetic}}),globalExceptions=converted.filter((group)=>group.enabled).flatMap((group)=>group.network.filter((rule)=>rule.exception)),lists=[],emit=(id,kind,enabled,ordinary,exceptions)=>{if(!ordinary.length)return;const room=max-exceptions.length;if(room<1)throw Error(`[browser-extension] group "${id}" needs ${exceptions.length} exception rules in every list, which leaves no room under maxRulesPerList (${max})`);chunk(ordinary,room).forEach((part,index)=>{const rules=[...part,...exceptions].sort(byPrecedence).map((entry)=>entry.rule);lists.push({id:index?`${id}-${index+1}`:id,version:webkitListVersion(rules),group:id,kind,enabled,rules})})};for(const group of converted){const own=group.network.filter((rule)=>rule.exception),exceptions=group.enabled?globalExceptions:[...globalExceptions,...own];emit(group.id,"network",group.enabled,group.network.filter((rule)=>!rule.exception),exceptions);emit(group.network.length?`${group.id}-cosmetic`:group.id,"cosmetic",group.enabled,group.cosmetic,[])}return{lists,report:{input,output,unsupported:unsupported.list(),approximated:approximated.list()}}}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacksjs/browser-extension",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.75.
|
|
4
|
+
"version": "0.75.417",
|
|
5
5
|
"description": "Build MV3 browser extensions (Chrome, Firefox, Safari) the Stacks way — manifest, content/background scripts, DNR rules, packaging, Safari container app, all config-driven.",
|
|
6
6
|
"author": "Chris Breuer",
|
|
7
7
|
"contributors": [
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
"prepublishOnly": "bun run build"
|
|
64
64
|
},
|
|
65
65
|
"dependencies": {
|
|
66
|
-
"bun-plugin-stx": "0.2.
|
|
66
|
+
"bun-plugin-stx": "0.2.780",
|
|
67
67
|
"ts-pantry": "^0.11.146"
|
|
68
68
|
},
|
|
69
69
|
"peerDependencies": {
|