extrojs 0.1.0 → 0.3.0

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 (96) hide show
  1. package/README.md +3 -3
  2. package/client.d.ts +8 -0
  3. package/dist/cli.d.ts +7 -0
  4. package/dist/cli.js +27 -0
  5. package/dist/commands/build.d.ts +5 -0
  6. package/dist/commands/build.js +26 -0
  7. package/dist/commands/dev.d.ts +7 -0
  8. package/dist/commands/dev.js +156 -0
  9. package/dist/config.d.ts +2 -2
  10. package/dist/core/asset.d.ts +10 -0
  11. package/dist/core/asset.js +10 -0
  12. package/dist/dev-assets.d.ts +3 -3
  13. package/dist/dev-assets.js +33 -12
  14. package/dist/env.d.ts +10 -0
  15. package/dist/env.js +18 -0
  16. package/dist/exports/asset.d.ts +1 -0
  17. package/dist/exports/asset.js +1 -0
  18. package/dist/exports/link.d.ts +1 -0
  19. package/dist/exports/link.js +1 -0
  20. package/dist/exports/navigation.d.ts +2 -0
  21. package/dist/exports/navigation.js +1 -0
  22. package/dist/exports/runtime.d.ts +2 -0
  23. package/dist/exports/runtime.js +3 -0
  24. package/dist/index.js +5 -136
  25. package/dist/load-config.d.ts +1 -1
  26. package/dist/logger.d.ts +34 -0
  27. package/dist/logger.js +65 -0
  28. package/dist/paths.d.ts +8 -0
  29. package/dist/paths.js +8 -0
  30. package/dist/pkg.d.ts +6 -0
  31. package/dist/pkg.js +5 -0
  32. package/dist/plugin/app-tree.d.ts +59 -0
  33. package/dist/plugin/app-tree.js +214 -0
  34. package/dist/plugin/asset-inventory.d.ts +24 -0
  35. package/dist/plugin/asset-inventory.js +9 -0
  36. package/dist/plugin/dev-reactions.d.ts +59 -0
  37. package/dist/plugin/dev-reactions.js +62 -0
  38. package/dist/plugin/emit-assets.d.ts +50 -0
  39. package/dist/plugin/emit-assets.js +40 -0
  40. package/dist/plugin/generators/html.d.ts +39 -0
  41. package/dist/plugin/generators/html.js +127 -0
  42. package/dist/plugin/generators/icons.d.ts +15 -0
  43. package/dist/plugin/generators/icons.js +16 -0
  44. package/dist/plugin/generators/public.d.ts +17 -0
  45. package/dist/plugin/generators/public.js +20 -0
  46. package/dist/plugin/icons.d.ts +5 -0
  47. package/dist/plugin/icons.js +20 -0
  48. package/dist/plugin/index.d.ts +31 -0
  49. package/dist/plugin/index.js +246 -0
  50. package/dist/plugin/internal.d.ts +14 -0
  51. package/dist/plugin/internal.js +6 -0
  52. package/dist/plugin/manifest.d.ts +29 -0
  53. package/dist/plugin/manifest.js +68 -0
  54. package/dist/plugin/public.d.ts +21 -0
  55. package/dist/plugin/public.js +63 -0
  56. package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
  57. package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
  58. package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
  59. package/dist/plugin/runtimes/csui-mount.js +21 -0
  60. package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
  61. package/dist/plugin/runtimes/dev-bridge.js +19 -0
  62. package/dist/plugin/runtimes/routes-module.d.ts +20 -0
  63. package/dist/plugin/runtimes/routes-module.js +51 -0
  64. package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
  65. package/dist/plugin/runtimes/runtime-module.js +40 -0
  66. package/dist/plugin/surfaces.d.ts +37 -0
  67. package/dist/plugin/surfaces.js +67 -0
  68. package/dist/plugin/types/index.d.ts +9 -0
  69. package/dist/plugin/types/index.js +1 -0
  70. package/dist/plugin/utils/read-json.d.ts +1 -0
  71. package/dist/plugin/utils/read-json.js +8 -0
  72. package/dist/react/env.d.ts +13 -0
  73. package/dist/react/env.js +1 -0
  74. package/dist/router/build-tree.d.ts +46 -0
  75. package/dist/router/build-tree.js +56 -0
  76. package/dist/router/context.d.ts +13 -0
  77. package/dist/router/context.js +2 -0
  78. package/dist/router/create-router.d.ts +10 -0
  79. package/dist/router/create-router.js +126 -0
  80. package/dist/router/defaults.d.ts +24 -0
  81. package/dist/router/defaults.js +25 -0
  82. package/dist/router/error-boundary.d.ts +23 -0
  83. package/dist/router/error-boundary.js +21 -0
  84. package/dist/router/hooks.d.ts +18 -0
  85. package/dist/router/hooks.js +34 -0
  86. package/dist/router/index.d.ts +8 -0
  87. package/dist/router/index.js +7 -0
  88. package/dist/router/link.d.ts +305 -0
  89. package/dist/router/link.js +30 -0
  90. package/dist/router/match.d.ts +14 -0
  91. package/dist/router/match.js +27 -0
  92. package/dist/router/types.d.ts +55 -0
  93. package/dist/router/types.js +1 -0
  94. package/dist/types/index.d.ts +152 -0
  95. package/dist/types/index.js +1 -0
  96. package/package.json +47 -9
@@ -0,0 +1,63 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * @file public.ts
5
+ * @description Discovers Public assets (static files under the project-root
6
+ * `public/` directory) and partitions them against the names Extro itself
7
+ * emits, so a stray `public/manifest.json` can never shadow the real one.
8
+ * The same partition drives prod emission, dev copy, and the
9
+ * `web_accessible_resources` list, so all three agree on exactly what ships.
10
+ */
11
+ export const PUBLIC_DIR = "public";
12
+ /** Recursively list files under `dir` as posix-relative paths from `base`. */
13
+ function walk(dir, base) {
14
+ const out = [];
15
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
16
+ const abs = path.join(dir, entry.name);
17
+ if (entry.isDirectory()) {
18
+ out.push(...walk(abs, base));
19
+ }
20
+ else {
21
+ out.push(path.relative(base, abs).split(path.sep).join("/"));
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+ /**
27
+ * Names a Public asset must not overwrite: the generated manifest, the
28
+ * per-surface HTML shells and script bundles, and the dedicated `icons/`
29
+ * tree. Generated output always wins.
30
+ */
31
+ function isReservedName(name, tree) {
32
+ if (name === "manifest.json")
33
+ return true;
34
+ if (name === "icons" || name.startsWith("icons/"))
35
+ return true;
36
+ for (const surface of Object.keys(tree.surfaces)) {
37
+ if (name === `${surface}.html` || name === `${surface}.js`)
38
+ return true;
39
+ }
40
+ if (tree.scripts.background && name === "background.js")
41
+ return true;
42
+ if (tree.scripts.content && name === "content.js")
43
+ return true;
44
+ return false;
45
+ }
46
+ /**
47
+ * Partition `public/` into shippable files and conflicts. Returns empty
48
+ * lists when `public/` is absent.
49
+ */
50
+ export function collectPublicAssets(root, tree) {
51
+ const dir = path.join(root, PUBLIC_DIR);
52
+ if (!fs.existsSync(dir))
53
+ return { files: [], conflicts: [] };
54
+ const files = [];
55
+ const conflicts = [];
56
+ for (const rel of walk(dir, dir).sort()) {
57
+ if (isReservedName(rel, tree))
58
+ conflicts.push(rel);
59
+ else
60
+ files.push(rel);
61
+ }
62
+ return { files, conflicts };
63
+ }
@@ -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);
@@ -0,0 +1,194 @@
1
+ import "virtual:extro/user/background";
2
+ import { config } from "virtual:extro/dev-bridge/config";
3
+ /**
4
+ * @file runtimes/clients/dev-bridge.ts
5
+ * @description The MV3 service-worker bridge run by `extro dev`.
6
+ *
7
+ * Responsibilities:
8
+ * - Connect to the CLI's signal WS and react to:
9
+ * bg-rebuilt → `chrome.runtime.reload()` (only way to swap BG code).
10
+ * cs-rebuilt → CSUI-aware re-mount via `csui-update`, else tab reload.
11
+ * vite-hmr → fetch transformed module source from the dev server
12
+ * and forward to tabs as `rfr-update` for the CSUI
13
+ * runtime's react-refresh client.
14
+ * - Keep the service worker alive past the MV3 idle timeout so the WS
15
+ * doesn't die between edits.
16
+ * - Restart the extension if the in-memory manifest predates this dev
17
+ * session (CSP would block our signal WS otherwise).
18
+ *
19
+ * Why the BG SW does the dev-server fetch (not content scripts):
20
+ * Chrome's Local Network Access blocks public HTTPS pages from reaching
21
+ * localhost without a per-site user grant. Only the chrome-extension://
22
+ * origin can reliably fetch http://localhost:<vitePort>.
23
+ */
24
+ (function extroDevBridge() {
25
+ // If Chrome's in-memory manifest predates this dev session (e.g. it last
26
+ // loaded from a prod build), the CSP won't allow our signal WS. Force a
27
+ // reload so Chrome re-reads the dev manifest from disk. The new SW spawn
28
+ // sees the updated CSP and skips this branch.
29
+ const csp = chrome.runtime.getManifest().content_security_policy;
30
+ const pages = csp?.extension_pages ?? "";
31
+ if (!pages.includes(`ws://localhost:${config.signalPort}`)) {
32
+ chrome.runtime.reload();
33
+ return;
34
+ }
35
+ const SIGNAL_URL = `ws://localhost:${config.signalPort}`;
36
+ const VITE_ORIGIN = `http://localhost:${config.vitePort}`;
37
+ let signalSocket = null;
38
+ let wasDisconnected = false;
39
+ // ---------------------------------------------------------------------
40
+ // Keep-alive — Chrome MV3 terminates idle SWs after ~30s. Without this,
41
+ // the signal WS dies between edits and HMR silently stops working.
42
+ // Periodic chrome.* API calls register as activity and prevent
43
+ // termination. Dev-only; the prod build doesn't ship this bridge.
44
+ // ---------------------------------------------------------------------
45
+ setInterval(() => {
46
+ chrome.runtime.getPlatformInfo().catch(() => { });
47
+ }, 20_000);
48
+ // ---------------------------------------------------------------------
49
+ // Tab helpers
50
+ // ---------------------------------------------------------------------
51
+ const messageMatchingTabs = async (msg) => {
52
+ let count = 0;
53
+ try {
54
+ const manifest = chrome.runtime.getManifest();
55
+ const matches = manifest.content_scripts?.[0]?.matches ?? [];
56
+ if (matches.length === 0)
57
+ return 0;
58
+ const tabs = await chrome.tabs.query({ url: matches });
59
+ await Promise.all(tabs.map(async (tab) => {
60
+ if (tab.id == null)
61
+ return;
62
+ try {
63
+ await chrome.tabs.sendMessage(tab.id, msg);
64
+ count++;
65
+ }
66
+ catch { }
67
+ }));
68
+ }
69
+ catch { }
70
+ return count;
71
+ };
72
+ const reloadMatchingTabs = async () => {
73
+ try {
74
+ const manifest = chrome.runtime.getManifest();
75
+ const matches = manifest.content_scripts?.[0]?.matches ?? [];
76
+ if (matches.length === 0)
77
+ return;
78
+ const tabs = await chrome.tabs.query({ url: matches });
79
+ for (const tab of tabs) {
80
+ if (tab.id != null) {
81
+ try {
82
+ await chrome.tabs.reload(tab.id);
83
+ }
84
+ catch { }
85
+ }
86
+ }
87
+ }
88
+ catch (err) {
89
+ console.warn("[extro] tab reload skipped:", err);
90
+ }
91
+ };
92
+ // ---------------------------------------------------------------------
93
+ // RFR transport — fetch transformed module source from the Vite dev
94
+ // server (BG SW is the only origin that reliably reaches localhost on
95
+ // sites where Local Network Access would block a public-origin fetch).
96
+ // ---------------------------------------------------------------------
97
+ const fetchModuleSource = async (modPath) => {
98
+ try {
99
+ const res = await fetch(VITE_ORIGIN + modPath);
100
+ if (!res.ok)
101
+ return null;
102
+ return await res.text();
103
+ }
104
+ catch (err) {
105
+ console.warn("[extro] failed to fetch " + modPath, err);
106
+ return null;
107
+ }
108
+ };
109
+ // ---------------------------------------------------------------------
110
+ // Signal handlers
111
+ // ---------------------------------------------------------------------
112
+ const onBgRebuilt = () => {
113
+ console.log("[extro] bg-rebuilt signal received");
114
+ // A service worker can't be hot-swapped; the only way to pick up new
115
+ // background code is to restart the extension. Deliberately does NOT
116
+ // message tabs or send csui-update — a BG-only edit leaves content
117
+ // scripts / CSUI alone.
118
+ chrome.runtime.reload();
119
+ };
120
+ const onCsRebuilt = async () => {
121
+ console.log("[extro] cs-rebuilt signal received");
122
+ if (config.hasCSUI) {
123
+ const count = await messageMatchingTabs({ kind: "csui-update" });
124
+ console.log(`[extro] csui-update sent to ${count} tab(s)`);
125
+ }
126
+ else {
127
+ await reloadMatchingTabs();
128
+ }
129
+ // Deliberately NO chrome.runtime.reload() — a content-only edit is
130
+ // picked up when the tab reloads (content.js re-injected) or when
131
+ // CSUI soft-remounts.
132
+ };
133
+ const onViteHmr = async (payload) => {
134
+ // Forward the raw payload for any future consumer that wants the
135
+ // unmodified Vite envelope.
136
+ await messageMatchingTabs({ kind: "vite-hmr", payload });
137
+ if (!config.hasCSUI || !payload || !Array.isArray(payload.updates))
138
+ return;
139
+ const fetched = await Promise.all(payload.updates
140
+ .filter((u) => u && u.type === "js-update" && u.path)
141
+ .map(async (u) => {
142
+ const code = await fetchModuleSource(u.path);
143
+ return code ? { path: u.path, code, timestamp: u.timestamp } : null;
144
+ }));
145
+ const modules = fetched.filter((m) => m !== null);
146
+ if (modules.length === 0)
147
+ return;
148
+ const count = await messageMatchingTabs({ kind: "rfr-update", modules });
149
+ console.log(`[extro] rfr-update ${modules.length} module(s) -> ${count} tab(s)`);
150
+ };
151
+ // ---------------------------------------------------------------------
152
+ // Signal WS
153
+ // ---------------------------------------------------------------------
154
+ const connectSignal = () => {
155
+ signalSocket = new WebSocket(SIGNAL_URL);
156
+ signalSocket.addEventListener("open", () => {
157
+ // Wipe the pile of "WebSocket connection failed" entries Chrome
158
+ // accumulated while dev was down. Only on reconnect, not the very
159
+ // first connect, so we don't eat the user's pre-bridge BG logs.
160
+ if (wasDisconnected)
161
+ console.clear();
162
+ wasDisconnected = false;
163
+ console.log("[extro] Connected to dev server.");
164
+ });
165
+ signalSocket.addEventListener("message", (event) => {
166
+ let msg;
167
+ try {
168
+ msg = JSON.parse(event.data);
169
+ }
170
+ catch {
171
+ return;
172
+ }
173
+ if (!msg)
174
+ return;
175
+ if (msg.kind === "bg-rebuilt")
176
+ onBgRebuilt();
177
+ else if (msg.kind === "cs-rebuilt")
178
+ onCsRebuilt();
179
+ else if (msg.kind === "vite-hmr" && msg.payload)
180
+ onViteHmr(msg.payload);
181
+ });
182
+ signalSocket.addEventListener("close", () => {
183
+ wasDisconnected = true;
184
+ setTimeout(connectSignal, 1000);
185
+ });
186
+ signalSocket.addEventListener("error", () => {
187
+ try {
188
+ signalSocket?.close();
189
+ }
190
+ catch { }
191
+ });
192
+ };
193
+ connectSignal();
194
+ })();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @file runtimes/csui-mount.ts
3
+ * @description Plugin-side loader for the CSUI Mount Runtime module.
4
+ *
5
+ * Owns the virtual IDs this runtime consumes and emits source for each.
6
+ * The runtime client itself lives at `runtimes/clients/csui-mount.ts`
7
+ * (a real TS file compiled separately via `tsconfig.runtime.json`); this
8
+ * loader only reads the compiled JS and emits the small config / user-
9
+ * import virtuals it imports.
10
+ */
11
+ export declare const CSUI_ENTRY_ID = "virtual:extro/csui-content";
12
+ export declare const CSUI_CONFIG_ID = "virtual:extro/csui-mount/config";
13
+ export declare const CSUI_USER_PAGE_ID = "virtual:extro/user/content-page";
14
+ export declare const CSUI_USER_SCRIPT_ID = "virtual:extro/user/content-script";
15
+ export declare const loadCSUIClient: () => string;
16
+ export declare const csuiConfigSource: (dev: boolean) => string;
17
+ export declare const csuiUserPageSource: (pagePath: string) => string;
18
+ export declare const csuiUserScriptSource: (scriptPath: string | undefined) => string;
@@ -0,0 +1,21 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * @file runtimes/csui-mount.ts
5
+ * @description Plugin-side loader for the CSUI Mount Runtime module.
6
+ *
7
+ * Owns the virtual IDs this runtime consumes and emits source for each.
8
+ * The runtime client itself lives at `runtimes/clients/csui-mount.ts`
9
+ * (a real TS file compiled separately via `tsconfig.runtime.json`); this
10
+ * loader only reads the compiled JS and emits the small config / user-
11
+ * import virtuals it imports.
12
+ */
13
+ export const CSUI_ENTRY_ID = "virtual:extro/csui-content";
14
+ export const CSUI_CONFIG_ID = "virtual:extro/csui-mount/config";
15
+ export const CSUI_USER_PAGE_ID = "virtual:extro/user/content-page";
16
+ export const CSUI_USER_SCRIPT_ID = "virtual:extro/user/content-script";
17
+ const clientJsURL = new URL("./clients/csui-mount.js", import.meta.url);
18
+ export const loadCSUIClient = () => fs.readFileSync(fileURLToPath(clientJsURL), "utf-8");
19
+ export const csuiConfigSource = (dev) => `export const config = ${JSON.stringify({ dev })};\n`;
20
+ export const csuiUserPageSource = (pagePath) => `export { default } from ${JSON.stringify(pagePath)};\n`;
21
+ export const csuiUserScriptSource = (scriptPath) => scriptPath ? `import ${JSON.stringify(scriptPath)};\n` : "\n";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @file runtimes/dev-bridge.ts
3
+ * @description Plugin-side loader for the Dev Bridge Runtime module.
4
+ *
5
+ * Owns the virtual IDs this runtime consumes and emits source for each.
6
+ * The runtime client itself lives at `runtimes/clients/dev-bridge.ts`
7
+ * (a real TS file compiled separately via `tsconfig.runtime.json`); this
8
+ * loader only reads the compiled JS and emits the small config / user-
9
+ * import virtuals it imports.
10
+ */
11
+ export declare const DEV_BRIDGE_ENTRY_ID = "virtual:extro/dev-background";
12
+ export declare const DEV_BRIDGE_CONFIG_ID = "virtual:extro/dev-bridge/config";
13
+ export declare const DEV_BRIDGE_USER_BG_ID = "virtual:extro/user/background";
14
+ export declare const loadDevBridgeClient: () => string;
15
+ interface DevBridgeConfig {
16
+ signalPort: number;
17
+ vitePort: number;
18
+ hasCSUI: boolean;
19
+ }
20
+ export declare const devBridgeConfigSource: (cfg: DevBridgeConfig) => string;
21
+ export declare const devBridgeUserBackgroundSource: (backgroundPath: string | undefined) => string;
22
+ export {};
@@ -0,0 +1,19 @@
1
+ import fs from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ /**
4
+ * @file runtimes/dev-bridge.ts
5
+ * @description Plugin-side loader for the Dev Bridge Runtime module.
6
+ *
7
+ * Owns the virtual IDs this runtime consumes and emits source for each.
8
+ * The runtime client itself lives at `runtimes/clients/dev-bridge.ts`
9
+ * (a real TS file compiled separately via `tsconfig.runtime.json`); this
10
+ * loader only reads the compiled JS and emits the small config / user-
11
+ * import virtuals it imports.
12
+ */
13
+ export const DEV_BRIDGE_ENTRY_ID = "virtual:extro/dev-background";
14
+ export const DEV_BRIDGE_CONFIG_ID = "virtual:extro/dev-bridge/config";
15
+ export const DEV_BRIDGE_USER_BG_ID = "virtual:extro/user/background";
16
+ const clientJsURL = new URL("./clients/dev-bridge.js", import.meta.url);
17
+ export const loadDevBridgeClient = () => fs.readFileSync(fileURLToPath(clientJsURL), "utf-8");
18
+ export const devBridgeConfigSource = (cfg) => `export const config = ${JSON.stringify(cfg)};\n`;
19
+ export const devBridgeUserBackgroundSource = (backgroundPath) => backgroundPath ? `import ${JSON.stringify(backgroundPath)};\n` : "\n";
@@ -0,0 +1,20 @@
1
+ import type { RouteManifest } from "../../types/index.js";
2
+ /**
3
+ * @file runtimes/routes-module.ts
4
+ * @description The single codegen for the `virtual:extro/routes/<surface>`
5
+ * Runtime module (ADR 0005). It is the only code that knows the text form of
6
+ * the routing contract: lazy `import()` thunks, the dynamic-route RegExp, and
7
+ * the `notFound` / `rootLayout` exports. Input is the typed `RouteManifest`;
8
+ * output is asserted against the runtime `Route[]` type by the round-trip
9
+ * test, so this string can no longer drift from what the runtime expects.
10
+ *
11
+ * Example output:
12
+ *
13
+ * export const routes = [
14
+ * { type: "static", path: "/", boundaries: [...], load: () => import("...") },
15
+ * { type: "dynamic", path: "/user/:id", paramKeys: ["id"], pattern: new RegExp("^/user/([^/]+)$"), boundaries: [...], load: () => import("...") },
16
+ * ];
17
+ * export const notFound = () => import("...");
18
+ * export const rootLayout = null;
19
+ */
20
+ export declare function emit(manifest: RouteManifest): string;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * @file runtimes/routes-module.ts
3
+ * @description The single codegen for the `virtual:extro/routes/<surface>`
4
+ * Runtime module (ADR 0005). It is the only code that knows the text form of
5
+ * the routing contract: lazy `import()` thunks, the dynamic-route RegExp, and
6
+ * the `notFound` / `rootLayout` exports. Input is the typed `RouteManifest`;
7
+ * output is asserted against the runtime `Route[]` type by the round-trip
8
+ * test, so this string can no longer drift from what the runtime expects.
9
+ *
10
+ * Example output:
11
+ *
12
+ * export const routes = [
13
+ * { type: "static", path: "/", boundaries: [...], load: () => import("...") },
14
+ * { type: "dynamic", path: "/user/:id", paramKeys: ["id"], pattern: new RegExp("^/user/([^/]+)$"), boundaries: [...], load: () => import("...") },
15
+ * ];
16
+ * export const notFound = () => import("...");
17
+ * export const rootLayout = null;
18
+ */
19
+ export function emit(manifest) {
20
+ const entries = manifest.routes.map(serializeRoute).join(",\n");
21
+ return `export const routes = [
22
+ ${entries}
23
+ ];
24
+ export const notFound = ${loaderOrNull(manifest.notFound)};
25
+ export const rootLayout = ${loaderOrNull(manifest.rootLayout)};
26
+ `;
27
+ }
28
+ // ---------------------------------------------------------------------------
29
+ // Serialisers
30
+ // ---------------------------------------------------------------------------
31
+ /** A lazy import of an absolute path, or the `null` literal when absent. */
32
+ function loaderOrNull(file) {
33
+ return file ? `() => import(${JSON.stringify(file)})` : "null";
34
+ }
35
+ // Boundary chain is emitted outermost first as tagged lazy imports, so the
36
+ // runtime composes the route's wrappers without a separate tree fetch.
37
+ function serializeBoundaries(route) {
38
+ const entries = route.boundaries
39
+ .map((b) => `{ kind: ${JSON.stringify(b.kind)}, load: () => import(${JSON.stringify(b.file)}) }`)
40
+ .join(", ");
41
+ return `[${entries}]`;
42
+ }
43
+ function serializeRoute(route) {
44
+ if (route.type === "static") {
45
+ return ` { type: "static", path: ${JSON.stringify(route.path)}, boundaries: ${serializeBoundaries(route)}, load: () => import(${JSON.stringify(route.file)}) }`;
46
+ }
47
+ // `patternSource` is materialized into a real RegExp here (the runtime
48
+ // `Route` type carries `pattern: RegExp`); JSON.stringify keeps escaping
49
+ // correct without RegExp-literal pitfalls.
50
+ return ` { type: "dynamic", path: ${JSON.stringify(route.path)}, paramKeys: ${JSON.stringify(route.paramKeys)}, pattern: new RegExp(${JSON.stringify(route.patternSource)}), boundaries: ${serializeBoundaries(route)}, load: () => import(${JSON.stringify(route.file)}) }`;
51
+ }
@@ -0,0 +1,16 @@
1
+ import type { RoutableSurface } from "../surfaces.js";
2
+ interface GenerateRuntimeModuleOptions {
3
+ surface: RoutableSurface;
4
+ }
5
+ /**
6
+ * @file runtimes/runtime-module.ts
7
+ * @description Generates the per-surface runtime entry.
8
+ *
9
+ * The real runtime logic (mounting, route matching, render loop) lives in
10
+ * `@extrojs/router` as actual TypeScript; this file only emits a tiny
11
+ * shim that wires the compiled routes array into `createExtroRouter`. The
12
+ * shim imports from the published `extrojs/runtime` subpath (ADR 0009), the
13
+ * one path that resolves inside the user's bundle.
14
+ */
15
+ export declare function generateRuntimeModule({ surface, }: GenerateRuntimeModuleOptions): string;
16
+ export {};
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @file runtimes/runtime-module.ts
3
+ * @description Generates the per-surface runtime entry.
4
+ *
5
+ * The real runtime logic (mounting, route matching, render loop) lives in
6
+ * `@extrojs/router` as actual TypeScript; this file only emits a tiny
7
+ * shim that wires the compiled routes array into `createExtroRouter`. The
8
+ * shim imports from the published `extrojs/runtime` subpath (ADR 0009), the
9
+ * one path that resolves inside the user's bundle.
10
+ */
11
+ export function generateRuntimeModule({ surface, }) {
12
+ return `import { createExtroRouter } from "extrojs/runtime";
13
+ import { routes, notFound, rootLayout } from "virtual:extro/routes/${surface}";
14
+
15
+ // Persist the router handle across HMR updates so we never call createRoot twice.
16
+ // import.meta.hot.data survives module re-execution.
17
+ let handle = import.meta.hot?.data?.handle;
18
+
19
+ if (!handle) {
20
+ handle = createExtroRouter(routes, { surface: ${JSON.stringify(surface)}, notFound, rootLayout });
21
+ if (import.meta.hot) {
22
+ import.meta.hot.data.handle = handle;
23
+ }
24
+ } else {
25
+ handle.update(routes, { notFound, rootLayout });
26
+ }
27
+
28
+ if (import.meta.hot) {
29
+ // Only accept updates to the routes dep. Edits to anything else (e.g.
30
+ // the router internals) bubble to a full page reload. The persisted
31
+ // handle is wired to the old render closure and can't pick up new module
32
+ // code in place, so a fresh mount is the correct behavior.
33
+ import.meta.hot.accept("virtual:extro/routes/${surface}", (mod) => {
34
+ if (mod?.routes) {
35
+ handle.update(mod.routes, { notFound: mod.notFound, rootLayout: mod.rootLayout });
36
+ }
37
+ });
38
+ }
39
+ `;
40
+ }
@@ -0,0 +1,37 @@
1
+ import type { ExtroConfig, ManifestV3 } from "../types/index.js";
2
+ import type { AppTree } from "./app-tree.js";
3
+ export type RoutableSurface = "popup" | "options" | "sidepanel";
4
+ export type ScriptSurface = "background" | "content";
5
+ export type SurfaceName = RoutableSurface | ScriptSurface;
6
+ export type SurfaceKind = "routable" | "script";
7
+ export interface SurfaceContext {
8
+ tree: AppTree;
9
+ config: ExtroConfig;
10
+ /** Set during `extro dev` so descriptors can adjust for dev-mode behavior. */
11
+ dev?: {
12
+ port: number;
13
+ signalPort: number;
14
+ };
15
+ /**
16
+ * Shippable Public asset paths (posix, relative to `public/`). Computed once
17
+ * in `generateManifest` so descriptors stay pure. The Content descriptor
18
+ * lists them in `web_accessible_resources`.
19
+ */
20
+ publicAssets?: string[];
21
+ }
22
+ export interface SurfaceDescriptor {
23
+ name: SurfaceName;
24
+ kind: SurfaceKind;
25
+ /** Whether this surface is materialized for the current build. */
26
+ isPresent: (ctx: SurfaceContext) => boolean;
27
+ /** Manifest fragment merged into the final manifest when present. */
28
+ manifestContribution: (ctx: SurfaceContext) => Partial<ManifestV3>;
29
+ /** Permissions added when present and the user hasn't supplied their own list. */
30
+ permissions?: (ctx: SurfaceContext) => readonly string[];
31
+ /** Host permissions added when present and the user hasn't supplied their own list. */
32
+ hostPermissions?: (ctx: SurfaceContext) => readonly string[];
33
+ /** Content surface flag: also accept `page.tsx` as the CSUI Mode. */
34
+ acceptsCsuiPage?: boolean;
35
+ }
36
+ export declare const SURFACES: readonly SurfaceDescriptor[];
37
+ export declare function findSurface(name: string): SurfaceDescriptor | undefined;
@@ -0,0 +1,67 @@
1
+ export const SURFACES = [
2
+ {
3
+ name: "popup",
4
+ kind: "routable",
5
+ isPresent: ({ tree }) => !!tree.surfaces.popup,
6
+ manifestContribution: () => ({ action: { default_popup: "popup.html" } }),
7
+ },
8
+ {
9
+ name: "options",
10
+ kind: "routable",
11
+ isPresent: ({ tree }) => !!tree.surfaces.options,
12
+ manifestContribution: () => ({
13
+ options_ui: { page: "options.html", open_in_tab: true },
14
+ }),
15
+ },
16
+ {
17
+ name: "sidepanel",
18
+ kind: "routable",
19
+ isPresent: ({ tree }) => !!tree.surfaces.sidepanel,
20
+ manifestContribution: () => ({ side_panel: { default_path: "sidepanel.html" } }),
21
+ },
22
+ {
23
+ name: "background",
24
+ kind: "script",
25
+ // In dev the bridge runs as the background SW even when the user has
26
+ // no BG file, so the surface is forced present.
27
+ isPresent: ({ tree, dev }) => !!tree.scripts.background || !!dev,
28
+ manifestContribution: () => ({
29
+ background: { service_worker: "background.js" },
30
+ }),
31
+ // `tabs` is added in dev so the bridge can call chrome.tabs.reload.
32
+ permissions: ({ dev }) => (dev ? ["storage", "tabs"] : ["storage"]),
33
+ },
34
+ {
35
+ name: "content",
36
+ kind: "script",
37
+ acceptsCsuiPage: true,
38
+ isPresent: ({ tree }) => !!tree.scripts.content,
39
+ manifestContribution: ({ tree, config, publicAssets }) => {
40
+ const matches = config.content?.matches ?? ["<all_urls>"];
41
+ const fragment = {
42
+ content_scripts: [{ matches, js: ["content.js"] }],
43
+ };
44
+ // Anything a content script reaches via chrome.runtime.getURL must be
45
+ // declared accessible. The CSUI mount runtime dynamic-imports
46
+ // content.js; Public assets are getURL'd by user code. Both ride one
47
+ // entry scoped to the content script's matches.
48
+ const resources = [];
49
+ if (tree.scripts.content?.csui)
50
+ resources.push("content.js");
51
+ if (publicAssets?.length)
52
+ resources.push(...publicAssets);
53
+ if (resources.length) {
54
+ fragment.web_accessible_resources = [{ resources, matches }];
55
+ }
56
+ return fragment;
57
+ },
58
+ hostPermissions: ({ config }) => config.content?.matches ?? ["<all_urls>"],
59
+ },
60
+ ];
61
+ // ---------------------------------------------------------------------------
62
+ // Lookup
63
+ // ---------------------------------------------------------------------------
64
+ const BY_NAME = new Map(SURFACES.map((s) => [s.name, s]));
65
+ export function findSurface(name) {
66
+ return BY_NAME.get(name);
67
+ }