extrojs 0.2.0 → 0.3.1

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.
Files changed (83) hide show
  1. package/client.d.ts +7 -15
  2. package/dist/commands/build.js +1 -1
  3. package/dist/commands/dev.js +12 -29
  4. package/dist/config.d.ts +2 -2
  5. package/dist/core/asset.d.ts +10 -0
  6. package/dist/core/asset.js +10 -0
  7. package/dist/dev-assets.d.ts +3 -3
  8. package/dist/dev-assets.js +18 -16
  9. package/dist/exports/asset.d.ts +1 -0
  10. package/dist/exports/asset.js +1 -0
  11. package/dist/exports/link.d.ts +1 -0
  12. package/dist/exports/link.js +1 -0
  13. package/dist/exports/navigation.d.ts +2 -0
  14. package/dist/exports/navigation.js +1 -0
  15. package/dist/exports/runtime.d.ts +2 -0
  16. package/dist/exports/runtime.js +3 -0
  17. package/dist/load-config.d.ts +1 -1
  18. package/dist/paths.d.ts +1 -1
  19. package/dist/plugin/app-tree.d.ts +59 -0
  20. package/dist/plugin/app-tree.js +214 -0
  21. package/dist/plugin/asset-inventory.d.ts +24 -0
  22. package/dist/plugin/asset-inventory.js +9 -0
  23. package/dist/plugin/dev-reactions.d.ts +59 -0
  24. package/dist/plugin/dev-reactions.js +62 -0
  25. package/dist/plugin/emit-assets.d.ts +56 -0
  26. package/dist/plugin/emit-assets.js +47 -0
  27. package/dist/plugin/generators/html.d.ts +64 -0
  28. package/dist/plugin/generators/html.js +167 -0
  29. package/dist/plugin/generators/icons.d.ts +15 -0
  30. package/dist/plugin/generators/icons.js +16 -0
  31. package/dist/plugin/generators/public.d.ts +17 -0
  32. package/dist/plugin/generators/public.js +20 -0
  33. package/dist/plugin/icons.d.ts +5 -0
  34. package/dist/plugin/icons.js +20 -0
  35. package/dist/plugin/index.d.ts +31 -0
  36. package/dist/plugin/index.js +246 -0
  37. package/dist/plugin/internal.d.ts +14 -0
  38. package/dist/plugin/internal.js +6 -0
  39. package/dist/plugin/manifest.d.ts +29 -0
  40. package/dist/plugin/manifest.js +68 -0
  41. package/dist/plugin/public.d.ts +21 -0
  42. package/dist/plugin/public.js +69 -0
  43. package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
  44. package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
  45. package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
  46. package/dist/plugin/runtimes/csui-mount.js +21 -0
  47. package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
  48. package/dist/plugin/runtimes/dev-bridge.js +19 -0
  49. package/dist/plugin/runtimes/routes-module.d.ts +20 -0
  50. package/dist/plugin/runtimes/routes-module.js +51 -0
  51. package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
  52. package/dist/plugin/runtimes/runtime-module.js +40 -0
  53. package/dist/plugin/surfaces.d.ts +37 -0
  54. package/dist/plugin/surfaces.js +67 -0
  55. package/dist/plugin/types/index.d.ts +9 -0
  56. package/dist/plugin/types/index.js +1 -0
  57. package/dist/plugin/utils/read-json.d.ts +1 -0
  58. package/dist/plugin/utils/read-json.js +8 -0
  59. package/dist/react/env.d.ts +13 -0
  60. package/dist/react/env.js +1 -0
  61. package/dist/router/build-tree.d.ts +46 -0
  62. package/dist/router/build-tree.js +56 -0
  63. package/dist/router/context.d.ts +13 -0
  64. package/dist/router/context.js +2 -0
  65. package/dist/router/create-router.d.ts +10 -0
  66. package/dist/router/create-router.js +126 -0
  67. package/dist/router/defaults.d.ts +24 -0
  68. package/dist/router/defaults.js +25 -0
  69. package/dist/router/error-boundary.d.ts +23 -0
  70. package/dist/router/error-boundary.js +21 -0
  71. package/dist/router/hooks.d.ts +18 -0
  72. package/dist/router/hooks.js +34 -0
  73. package/dist/router/index.d.ts +8 -0
  74. package/dist/router/index.js +7 -0
  75. package/dist/router/link.d.ts +305 -0
  76. package/dist/router/link.js +30 -0
  77. package/dist/router/match.d.ts +14 -0
  78. package/dist/router/match.js +27 -0
  79. package/dist/router/types.d.ts +55 -0
  80. package/dist/router/types.js +1 -0
  81. package/dist/types/index.d.ts +152 -0
  82. package/dist/types/index.js +1 -0
  83. package/package.json +39 -7
@@ -0,0 +1,246 @@
1
+ import path from "node:path";
2
+ import { scanAppTree, routeManifest, APP_FILE_BASENAMES, } from "./app-tree.js";
3
+ import { emitAssets } from "./emit-assets.js";
4
+ import { discoverAssets } from "./asset-inventory.js";
5
+ import { decideTreeReaction } from "./dev-reactions.js";
6
+ import { SURFACES } from "./surfaces.js";
7
+ import { emitIcons } from "./generators/icons.js";
8
+ import { emitPublicAssets } from "./generators/public.js";
9
+ import { emit } from "./runtimes/routes-module.js";
10
+ import { generateRuntimeModule } from "./runtimes/runtime-module.js";
11
+ import { DEV_BRIDGE_ENTRY_ID, DEV_BRIDGE_CONFIG_ID, DEV_BRIDGE_USER_BG_ID, loadDevBridgeClient, devBridgeConfigSource, devBridgeUserBackgroundSource, } from "./runtimes/dev-bridge.js";
12
+ import { CSUI_ENTRY_ID, CSUI_CONFIG_ID, CSUI_USER_PAGE_ID, CSUI_USER_SCRIPT_ID, loadCSUIClient, csuiConfigSource, csuiUserPageSource, csuiUserScriptSource, } from "./runtimes/csui-mount.js";
13
+ import { readJson } from "./utils/read-json.js";
14
+ // Virtual IDs follow a slash-namespaced convention so each surface has its
15
+ // own runtime + routes module. Resolved IDs are prefixed with "\0" per
16
+ // Rollup's convention for internal/virtual modules.
17
+ const routesId = (surface) => `virtual:extro/routes/${surface}`;
18
+ const runtimeId = (surface) => `virtual:extro/runtime/${surface}`;
19
+ const resolved = (id) => `\0${id}`;
20
+ export function extro(options) {
21
+ const root = options.root;
22
+ const config = options.config ?? {};
23
+ const scriptsOnly = options.scriptsOnly ?? false;
24
+ const devBridge = options.devBridge;
25
+ const broadcastHmr = options.broadcastHmr;
26
+ let tree = { scripts: {}, surfaces: {} };
27
+ let pkg = {};
28
+ return {
29
+ name: "extro",
30
+ async config() {
31
+ tree = await scanAppTree(root);
32
+ const empty = Object.keys(tree.scripts).length === 0 &&
33
+ Object.keys(tree.surfaces).length === 0;
34
+ if (empty && !devBridge) {
35
+ // In dev with bridge, an entry-less project still gets a synthesized
36
+ // background SW for HMR signalling. Otherwise, complain.
37
+ throw new Error("Extro: No extension entrypoints found.\n\nExpected files like:\n src/app/popup/page.tsx\n src/app/options/page.tsx\n src/app/sidepanel/page.tsx\n src/app/background/index.ts\n src/app/content/index.ts");
38
+ }
39
+ pkg = readJson("package.json", root) ?? {};
40
+ const input = {};
41
+ const contentEntry = tree.scripts.content?.csui
42
+ ? CSUI_ENTRY_ID
43
+ : tree.scripts.content?.script;
44
+ if (devBridge) {
45
+ // Force a background entry in dev — wraps user's BG (if any) with
46
+ // the WS bridge.
47
+ input.background = DEV_BRIDGE_ENTRY_ID;
48
+ if (contentEntry)
49
+ input.content = contentEntry;
50
+ }
51
+ else if (scriptsOnly) {
52
+ if (tree.scripts.background)
53
+ input.background = tree.scripts.background;
54
+ if (contentEntry)
55
+ input.content = contentEntry;
56
+ }
57
+ else {
58
+ if (tree.scripts.background)
59
+ input.background = tree.scripts.background;
60
+ if (contentEntry)
61
+ input.content = contentEntry;
62
+ for (const surface of Object.keys(tree.surfaces)) {
63
+ input[surface] = runtimeId(surface);
64
+ }
65
+ }
66
+ return {
67
+ // Extro owns `public/` emission through its own pipeline (collision
68
+ // guard + WAR list + dev-output parity), so Vite's native copy is
69
+ // off. See ADR 0004.
70
+ publicDir: false,
71
+ // Only EXTRO_PUBLIC_* is inlined into surfaces via import.meta.env.
72
+ // Exactly EXTRO_PUBLIC_, never EXTRO_ (that would leak EXTRO_CRX_KEY
73
+ // and other build-time vars into bundles). See ADR 0002.
74
+ envPrefix: "EXTRO_PUBLIC_",
75
+ build: {
76
+ rollupOptions: {
77
+ input,
78
+ output: {
79
+ entryFileNames: "[name].js",
80
+ },
81
+ },
82
+ },
83
+ };
84
+ },
85
+ async generateBundle() {
86
+ if (scriptsOnly)
87
+ return;
88
+ // One discovery pass feeds the manifest (pure), the icon emit, and the
89
+ // public emit. See the Asset inventory.
90
+ const inventory = discoverAssets(root, tree);
91
+ await emitAssets({ tree, inventory, pkg, config }, (fileName, source) => {
92
+ this.emitFile({ type: "asset", fileName, source });
93
+ });
94
+ emitIcons({ ctx: this, root, icons: inventory.icons });
95
+ emitPublicAssets({ ctx: this, root, publicAssets: inventory.public });
96
+ },
97
+ configureServer(server) {
98
+ if (scriptsOnly)
99
+ return;
100
+ const basenames = APP_FILE_BASENAMES.join(",");
101
+ server.watcher.add(path.join(root, `src/app/**/{${basenames}}.{ts,tsx}`));
102
+ // Warm the CSUI module into the dev server's module graph so its
103
+ // transitive imports are tracked too. Without this, content files
104
+ // never appear in handleHotUpdate's ctx.modules (the dev server
105
+ // only owns the routable surfaces — popup / options / sidepanel —
106
+ // as Rollup inputs), so edits under src/app/content/ would never
107
+ // produce an HMR payload for the BG-fetch RFR transport.
108
+ const csui = tree.scripts.content?.csui;
109
+ if (csui && broadcastHmr) {
110
+ const url = "/" + path.relative(root, csui).split(path.sep).join("/");
111
+ server.warmupRequest(url).catch(() => { });
112
+ }
113
+ // Same source as the scanner glob — see APP_FILE_BASENAMES.
114
+ const isAppEntry = new RegExp(`^src/app/[^/]+/(?:.+/)?(?:${APP_FILE_BASENAMES.join("|")})\\.tsx?$`);
115
+ const routableSurfaceList = SURFACES
116
+ .filter((s) => s.kind === "routable")
117
+ .map((s) => s.name);
118
+ const handleChange = async (file) => {
119
+ const rel = path.relative(root, file).split(path.sep).join("/");
120
+ if (!isAppEntry.test(rel))
121
+ return;
122
+ const prevTree = tree;
123
+ tree = await scanAppTree(root);
124
+ // Decide the Dev reaction (pure); the handler owns the effects.
125
+ const reaction = decideTreeReaction(prevTree, tree, routableSurfaceList);
126
+ if (reaction.kind === "restart") {
127
+ // A Surface born mid-session: its Rollup input was fixed at config()
128
+ // time, so a fresh dev session is required to register the new entry.
129
+ const what = reaction.surface === "background" || reaction.surface === "content"
130
+ ? `${reaction.surface} entrypoint`
131
+ : `${reaction.surface} surface`;
132
+ console.log(`\n[extro] New ${what} detected. Restart \`extro dev\` to pick it up.\n`);
133
+ return;
134
+ }
135
+ if (reaction.kind === "invalidate") {
136
+ // Reload each changed surface's routes virtual module; the runtime
137
+ // module's accept("virtual:extro/routes/<surface>") boundary picks
138
+ // up the new array and calls handle.update without a remount.
139
+ for (const surface of reaction.surfaces) {
140
+ const mod = server.moduleGraph.getModuleById(resolved(routesId(surface)));
141
+ if (mod) {
142
+ await server.reloadModule(mod);
143
+ console.log(`[extro] Routes updated for ${surface}.`);
144
+ }
145
+ }
146
+ }
147
+ };
148
+ server.watcher.on("add", handleChange);
149
+ server.watcher.on("unlink", handleChange);
150
+ },
151
+ resolveId(id) {
152
+ // Dev bridge virtuals
153
+ if (devBridge && id === DEV_BRIDGE_ENTRY_ID)
154
+ return resolved(DEV_BRIDGE_ENTRY_ID);
155
+ if (devBridge && id === DEV_BRIDGE_CONFIG_ID)
156
+ return resolved(DEV_BRIDGE_CONFIG_ID);
157
+ if (devBridge && id === DEV_BRIDGE_USER_BG_ID)
158
+ return resolved(DEV_BRIDGE_USER_BG_ID);
159
+ // CSUI virtuals
160
+ if (id === CSUI_ENTRY_ID)
161
+ return resolved(CSUI_ENTRY_ID);
162
+ if (id === CSUI_CONFIG_ID)
163
+ return resolved(CSUI_CONFIG_ID);
164
+ if (id === CSUI_USER_PAGE_ID)
165
+ return resolved(CSUI_USER_PAGE_ID);
166
+ if (id === CSUI_USER_SCRIPT_ID)
167
+ return resolved(CSUI_USER_SCRIPT_ID);
168
+ if (scriptsOnly)
169
+ return;
170
+ for (const desc of SURFACES) {
171
+ if (desc.kind !== "routable")
172
+ continue;
173
+ const surface = desc.name;
174
+ if (id === runtimeId(surface))
175
+ return resolved(runtimeId(surface));
176
+ if (id === routesId(surface))
177
+ return resolved(routesId(surface));
178
+ }
179
+ },
180
+ load(id) {
181
+ // Dev bridge: entry + its config + the optional user-background re-export.
182
+ if (devBridge && id === resolved(DEV_BRIDGE_ENTRY_ID)) {
183
+ return loadDevBridgeClient();
184
+ }
185
+ if (devBridge && id === resolved(DEV_BRIDGE_CONFIG_ID)) {
186
+ return devBridgeConfigSource({
187
+ signalPort: devBridge.signalPort,
188
+ vitePort: devBridge.vitePort,
189
+ hasCSUI: !!tree.scripts.content?.csui,
190
+ });
191
+ }
192
+ if (devBridge && id === resolved(DEV_BRIDGE_USER_BG_ID)) {
193
+ return devBridgeUserBackgroundSource(tree.scripts.background);
194
+ }
195
+ // CSUI mount: entry + its config + the user page + optional side-effect script.
196
+ const content = tree.scripts.content;
197
+ if (id === resolved(CSUI_ENTRY_ID) && content?.csui) {
198
+ return loadCSUIClient();
199
+ }
200
+ if (id === resolved(CSUI_CONFIG_ID)) {
201
+ return csuiConfigSource(!!devBridge);
202
+ }
203
+ if (id === resolved(CSUI_USER_PAGE_ID) && content?.csui) {
204
+ return csuiUserPageSource(content.csui);
205
+ }
206
+ if (id === resolved(CSUI_USER_SCRIPT_ID)) {
207
+ return csuiUserScriptSource(content?.script);
208
+ }
209
+ if (scriptsOnly)
210
+ return;
211
+ for (const desc of SURFACES) {
212
+ if (desc.kind !== "routable")
213
+ continue;
214
+ const surface = desc.name;
215
+ if (id === resolved(runtimeId(surface))) {
216
+ return generateRuntimeModule({ surface });
217
+ }
218
+ if (id === resolved(routesId(surface))) {
219
+ return emit(routeManifest(tree, surface));
220
+ }
221
+ }
222
+ },
223
+ handleHotUpdate(ctx) {
224
+ if (!broadcastHmr)
225
+ return;
226
+ // Mirror Vite's own HMR `update` payload shape. Modules without a
227
+ // resolved url (rare — e.g. virtual-only) are skipped; the CSUI
228
+ // re-mount signal still covers those via `cs-rebuilt`. Content
229
+ // files reach this list naturally because configureServer warms the
230
+ // CSUI module into the graph at startup.
231
+ const updates = ctx.modules
232
+ .filter((m) => !!m.url)
233
+ .map((m) => ({
234
+ type: m.type === "css" ? "css-update" : "js-update",
235
+ path: m.url,
236
+ acceptedPath: m.url,
237
+ timestamp: ctx.timestamp,
238
+ explicitImportRequired: false,
239
+ isWithinCircularImport: false,
240
+ }));
241
+ if (updates.length === 0)
242
+ return;
243
+ broadcastHmr({ type: "update", updates });
244
+ },
245
+ };
246
+ }
@@ -0,0 +1,14 @@
1
+ export { scanAppTree, routeManifest } from "./app-tree.js";
2
+ export type { AppTree, ContentSlot } from "./app-tree.js";
3
+ export type { ManifestRoute, RouteManifest } from "../types/index.js";
4
+ export { emitAssets, composeArtifacts } from "./emit-assets.js";
5
+ export type { EmitSink, AssetOptions, Artifacts } from "./emit-assets.js";
6
+ export { discoverAssets } from "./asset-inventory.js";
7
+ export type { AssetInventory } from "./asset-inventory.js";
8
+ export type { PublicAssets } from "./public.js";
9
+ export { classifyScriptChange, mergeDirty, resolveFlush } from "./dev-reactions.js";
10
+ export type { ScriptDirty } from "./dev-reactions.js";
11
+ export { renderDevScreen, devScreenStyles } from "./generators/html.js";
12
+ export type { DevScreen } from "./generators/html.js";
13
+ export { SURFACES } from "./surfaces.js";
14
+ export type { RoutableSurface, ScriptSurface, SurfaceName, SurfaceKind, SurfaceContext, SurfaceDescriptor, } from "./surfaces.js";
@@ -0,0 +1,6 @@
1
+ export { scanAppTree, routeManifest } from "./app-tree.js";
2
+ export { emitAssets, composeArtifacts } from "./emit-assets.js";
3
+ export { discoverAssets } from "./asset-inventory.js";
4
+ export { classifyScriptChange, mergeDirty, resolveFlush } from "./dev-reactions.js";
5
+ export { renderDevScreen, devScreenStyles } from "./generators/html.js";
6
+ export { SURFACES } from "./surfaces.js";
@@ -0,0 +1,29 @@
1
+ import type { ExtroConfig, ManifestV3 } from "../types/index.js";
2
+ import type { AppTree } from "./app-tree.js";
3
+ import type { AssetInventory } from "./asset-inventory.js";
4
+ interface GenerateManifestOptions {
5
+ tree: AppTree;
6
+ /**
7
+ * The build's discovered icons + Public assets. Passed in as data so this
8
+ * generator never touches the filesystem: its inputs are the test surface.
9
+ */
10
+ inventory: AssetInventory;
11
+ pkg: {
12
+ name?: string;
13
+ description?: string;
14
+ version?: string;
15
+ };
16
+ config: ExtroConfig;
17
+ /**
18
+ * When set, the manifest is generated for a dev session:
19
+ * - CSP relaxed for the Vite dev server + signal WS
20
+ * - Background descriptor reports present (bridge runs there)
21
+ * - Background descriptor adds `tabs` to permissions
22
+ */
23
+ dev?: {
24
+ port: number;
25
+ signalPort: number;
26
+ };
27
+ }
28
+ export declare function generateManifest({ tree, inventory, pkg, config, dev, }: GenerateManifestOptions): ManifestV3;
29
+ export {};
@@ -0,0 +1,68 @@
1
+ import { SURFACES } from "./surfaces.js";
2
+ export function generateManifest({ tree, inventory, pkg, config, dev, }) {
3
+ const manifest = {
4
+ manifest_version: 3,
5
+ name: config.name ?? pkg.name ?? "Extro Extension",
6
+ version: config.version ?? pkg.version ?? "0.0.1",
7
+ };
8
+ const description = config.description ?? pkg.description;
9
+ if (description) {
10
+ manifest.description = description;
11
+ }
12
+ const publicAssets = inventory.public.files;
13
+ const ctx = { tree, config, dev, publicAssets };
14
+ const permissions = new Set(config.permissions ?? []);
15
+ const hostPermissions = new Set(config.hostPermissions ?? []);
16
+ for (const desc of SURFACES) {
17
+ if (!desc.isPresent(ctx))
18
+ continue;
19
+ Object.assign(manifest, desc.manifestContribution(ctx));
20
+ if (!config.permissions && desc.permissions) {
21
+ for (const p of desc.permissions(ctx))
22
+ permissions.add(p);
23
+ }
24
+ if (!config.hostPermissions && desc.hostPermissions) {
25
+ for (const p of desc.hostPermissions(ctx))
26
+ hostPermissions.add(p);
27
+ }
28
+ }
29
+ if (permissions.size > 0) {
30
+ manifest.permissions = [...permissions];
31
+ }
32
+ if (hostPermissions.size > 0) {
33
+ manifest.host_permissions = [...hostPermissions];
34
+ }
35
+ const icons = config.icons ?? inventory.icons ?? undefined;
36
+ if (icons) {
37
+ manifest.icons = icons;
38
+ }
39
+ if (dev) {
40
+ // CSP relaxed for dev: Vite dev server (HTTP + HMR WS) + the CLI's
41
+ // signal WS that the dev bridge connects to.
42
+ const origin = `http://localhost:${dev.port}`;
43
+ const viteWs = `ws://localhost:${dev.port}`;
44
+ const signalWs = `ws://localhost:${dev.signalPort}`;
45
+ manifest.content_security_policy = {
46
+ extension_pages: [
47
+ `script-src 'self' ${origin} 'wasm-unsafe-eval'`,
48
+ `object-src 'self'`,
49
+ `connect-src 'self' ${origin} ${viteWs} ${signalWs}`,
50
+ ].join("; "),
51
+ };
52
+ }
53
+ // EXTRO_CRX_KEY pins the extension ID (ADR 0002). Set before the
54
+ // config.manifest merge so an explicit manifest.key still wins.
55
+ const crxKey = process.env.EXTRO_CRX_KEY;
56
+ if (crxKey) {
57
+ manifest.key = crxKey;
58
+ }
59
+ if (config.manifest) {
60
+ Object.assign(manifest, config.manifest);
61
+ }
62
+ // Final imperative hook: sees the fully generated manifest and may mutate it
63
+ // or return a replacement. Runs last so it can change anything (ADR 0008).
64
+ if (config.transformManifest) {
65
+ return config.transformManifest(manifest) ?? manifest;
66
+ }
67
+ return manifest;
68
+ }
@@ -0,0 +1,21 @@
1
+ import type { AppTree } from "./app-tree.js";
2
+ /**
3
+ * @file public.ts
4
+ * @description Discovers Public assets (static files under the project-root
5
+ * `public/` directory) and partitions them against the names Extro itself
6
+ * emits, so a stray `public/manifest.json` can never shadow the real one.
7
+ * The same partition drives prod emission, dev copy, and the
8
+ * `web_accessible_resources` list, so all three agree on exactly what ships.
9
+ */
10
+ export declare const PUBLIC_DIR = "public";
11
+ export interface PublicAssets {
12
+ /** Posix-relative paths that will ship, sorted for stable output. */
13
+ files: string[];
14
+ /** Files skipped because their name collides with a generated output. */
15
+ conflicts: string[];
16
+ }
17
+ /**
18
+ * Partition `public/` into shippable files and conflicts. Returns empty
19
+ * lists when `public/` is absent.
20
+ */
21
+ export declare function collectPublicAssets(root: string, tree: AppTree): PublicAssets;
@@ -0,0 +1,69 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { DEV_PROBE_FILE } from "./generators/html.js";
4
+ /**
5
+ * @file public.ts
6
+ * @description Discovers Public assets (static files under the project-root
7
+ * `public/` directory) and partitions them against the names Extro itself
8
+ * emits, so a stray `public/manifest.json` can never shadow the real one.
9
+ * The same partition drives prod emission, dev copy, and the
10
+ * `web_accessible_resources` list, so all three agree on exactly what ships.
11
+ */
12
+ export const PUBLIC_DIR = "public";
13
+ /** Recursively list files under `dir` as posix-relative paths from `base`. */
14
+ function walk(dir, base) {
15
+ const out = [];
16
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
17
+ const abs = path.join(dir, entry.name);
18
+ if (entry.isDirectory()) {
19
+ out.push(...walk(abs, base));
20
+ }
21
+ else {
22
+ out.push(path.relative(base, abs).split(path.sep).join("/"));
23
+ }
24
+ }
25
+ return out;
26
+ }
27
+ /**
28
+ * Names a Public asset must not overwrite: the generated manifest, the
29
+ * per-surface HTML shells and script bundles, and the dedicated `icons/`
30
+ * tree. Generated output always wins.
31
+ */
32
+ function isReservedName(name, tree) {
33
+ if (name === "manifest.json")
34
+ return true;
35
+ // Dev-only output, but reserved in all modes so the partition (and the
36
+ // web_accessible_resources list derived from it) doesn't depend on the
37
+ // build mode.
38
+ if (name === DEV_PROBE_FILE)
39
+ return true;
40
+ if (name === "icons" || name.startsWith("icons/"))
41
+ return true;
42
+ for (const surface of Object.keys(tree.surfaces)) {
43
+ if (name === `${surface}.html` || name === `${surface}.js`)
44
+ return true;
45
+ }
46
+ if (tree.scripts.background && name === "background.js")
47
+ return true;
48
+ if (tree.scripts.content && name === "content.js")
49
+ return true;
50
+ return false;
51
+ }
52
+ /**
53
+ * Partition `public/` into shippable files and conflicts. Returns empty
54
+ * lists when `public/` is absent.
55
+ */
56
+ export function collectPublicAssets(root, tree) {
57
+ const dir = path.join(root, PUBLIC_DIR);
58
+ if (!fs.existsSync(dir))
59
+ return { files: [], conflicts: [] };
60
+ const files = [];
61
+ const conflicts = [];
62
+ for (const rel of walk(dir, dir).sort()) {
63
+ if (isReservedName(rel, tree))
64
+ conflicts.push(rel);
65
+ else
66
+ files.push(rel);
67
+ }
68
+ return { files, conflicts };
69
+ }
@@ -0,0 +1,90 @@
1
+ import "virtual:extro/user/content-script";
2
+ import UserComponent from "virtual:extro/user/content-page";
3
+ import { config } from "virtual:extro/csui-mount/config";
4
+ import { createElement } from "react";
5
+ import { createRoot } from "react-dom/client";
6
+ import { flushSync } from "react-dom";
7
+ const STATE_KEY = "__extroCSUI__";
8
+ const getState = () => globalThis[STATE_KEY];
9
+ const setState = (state) => {
10
+ const slot = globalThis;
11
+ if (state)
12
+ slot[STATE_KEY] = state;
13
+ else
14
+ delete slot[STATE_KEY];
15
+ };
16
+ const teardownState = (state) => {
17
+ try {
18
+ state.root.unmount();
19
+ }
20
+ catch { }
21
+ try {
22
+ state.host.remove();
23
+ }
24
+ catch { }
25
+ if (state.handler) {
26
+ try {
27
+ chrome.runtime.onMessage.removeListener(state.handler);
28
+ }
29
+ catch { }
30
+ }
31
+ };
32
+ const mount = (Component) => {
33
+ const previous = getState();
34
+ // Build the new tree on top of the old without removing the old first.
35
+ // Both hosts use position:fixed at the same coordinates, so they perfectly
36
+ // overlap; the user sees no gap. The old host is torn down on the next
37
+ // frame, by which time React has committed + painted the new tree.
38
+ const host = document.createElement("div");
39
+ host.id = "extro-csui-root";
40
+ const shadow = host.attachShadow({ mode: "open" });
41
+ document.body.appendChild(host);
42
+ const root = createRoot(shadow);
43
+ // flushSync so the new tree is committed to the DOM before we return; the
44
+ // subsequent rAF then reliably runs after the new tree exists. Without it,
45
+ // React's async commit can race with the rAF callback and the teardown
46
+ // fires on a blank frame, producing an intermittent flash.
47
+ flushSync(() => root.render(createElement(Component)));
48
+ if (previous) {
49
+ requestAnimationFrame(() => teardownState(previous));
50
+ }
51
+ if (!config.dev) {
52
+ setState({ root, host });
53
+ return;
54
+ }
55
+ // In-flight gate: drop csui-update messages while a re-import is already
56
+ // running so we don't queue redundant mount/teardown cycles when several
57
+ // signals arrive in quick succession.
58
+ let importPending = false;
59
+ const handler = (raw) => {
60
+ const msg = raw;
61
+ if (!msg)
62
+ return;
63
+ // RFR transport probe — slice 1 just logs that BG-fetched module
64
+ // sources are arriving end-to-end. Evaluation + react-refresh wiring
65
+ // come in later slices.
66
+ if (msg.kind === "rfr-update" && Array.isArray(msg.modules)) {
67
+ const modules = msg.modules;
68
+ console.log("[extro] rfr-update received:", modules.map((m) => ({
69
+ path: m.path,
70
+ bytes: m.code ? m.code.length : 0,
71
+ })));
72
+ return;
73
+ }
74
+ if (msg.kind !== "csui-update")
75
+ return;
76
+ if (importPending)
77
+ return;
78
+ importPending = true;
79
+ const url = chrome.runtime.getURL("content.js") + "?v=" + Date.now();
80
+ import(/* @vite-ignore */ url)
81
+ .catch((err) => console.error("[extro] csui re-import failed:", err))
82
+ .finally(() => { importPending = false; });
83
+ };
84
+ try {
85
+ chrome.runtime.onMessage.addListener(handler);
86
+ }
87
+ catch { }
88
+ setState({ root, host, handler });
89
+ };
90
+ mount(UserComponent);