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.
- package/README.md +3 -3
- package/client.d.ts +8 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.js +27 -0
- package/dist/commands/build.d.ts +5 -0
- package/dist/commands/build.js +26 -0
- package/dist/commands/dev.d.ts +7 -0
- package/dist/commands/dev.js +156 -0
- package/dist/config.d.ts +2 -2
- package/dist/core/asset.d.ts +10 -0
- package/dist/core/asset.js +10 -0
- package/dist/dev-assets.d.ts +3 -3
- package/dist/dev-assets.js +33 -12
- package/dist/env.d.ts +10 -0
- package/dist/env.js +18 -0
- package/dist/exports/asset.d.ts +1 -0
- package/dist/exports/asset.js +1 -0
- package/dist/exports/link.d.ts +1 -0
- package/dist/exports/link.js +1 -0
- package/dist/exports/navigation.d.ts +2 -0
- package/dist/exports/navigation.js +1 -0
- package/dist/exports/runtime.d.ts +2 -0
- package/dist/exports/runtime.js +3 -0
- package/dist/index.js +5 -136
- package/dist/load-config.d.ts +1 -1
- package/dist/logger.d.ts +34 -0
- package/dist/logger.js +65 -0
- package/dist/paths.d.ts +8 -0
- package/dist/paths.js +8 -0
- package/dist/pkg.d.ts +6 -0
- package/dist/pkg.js +5 -0
- package/dist/plugin/app-tree.d.ts +59 -0
- package/dist/plugin/app-tree.js +214 -0
- package/dist/plugin/asset-inventory.d.ts +24 -0
- package/dist/plugin/asset-inventory.js +9 -0
- package/dist/plugin/dev-reactions.d.ts +59 -0
- package/dist/plugin/dev-reactions.js +62 -0
- package/dist/plugin/emit-assets.d.ts +50 -0
- package/dist/plugin/emit-assets.js +40 -0
- package/dist/plugin/generators/html.d.ts +39 -0
- package/dist/plugin/generators/html.js +127 -0
- package/dist/plugin/generators/icons.d.ts +15 -0
- package/dist/plugin/generators/icons.js +16 -0
- package/dist/plugin/generators/public.d.ts +17 -0
- package/dist/plugin/generators/public.js +20 -0
- package/dist/plugin/icons.d.ts +5 -0
- package/dist/plugin/icons.js +20 -0
- package/dist/plugin/index.d.ts +31 -0
- package/dist/plugin/index.js +246 -0
- package/dist/plugin/internal.d.ts +14 -0
- package/dist/plugin/internal.js +6 -0
- package/dist/plugin/manifest.d.ts +29 -0
- package/dist/plugin/manifest.js +68 -0
- package/dist/plugin/public.d.ts +21 -0
- package/dist/plugin/public.js +63 -0
- package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
- package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
- package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
- package/dist/plugin/runtimes/csui-mount.js +21 -0
- package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
- package/dist/plugin/runtimes/dev-bridge.js +19 -0
- package/dist/plugin/runtimes/routes-module.d.ts +20 -0
- package/dist/plugin/runtimes/routes-module.js +51 -0
- package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
- package/dist/plugin/runtimes/runtime-module.js +40 -0
- package/dist/plugin/surfaces.d.ts +37 -0
- package/dist/plugin/surfaces.js +67 -0
- package/dist/plugin/types/index.d.ts +9 -0
- package/dist/plugin/types/index.js +1 -0
- package/dist/plugin/utils/read-json.d.ts +1 -0
- package/dist/plugin/utils/read-json.js +8 -0
- package/dist/react/env.d.ts +13 -0
- package/dist/react/env.js +1 -0
- package/dist/router/build-tree.d.ts +46 -0
- package/dist/router/build-tree.js +56 -0
- package/dist/router/context.d.ts +13 -0
- package/dist/router/context.js +2 -0
- package/dist/router/create-router.d.ts +10 -0
- package/dist/router/create-router.js +126 -0
- package/dist/router/defaults.d.ts +24 -0
- package/dist/router/defaults.js +25 -0
- package/dist/router/error-boundary.d.ts +23 -0
- package/dist/router/error-boundary.js +21 -0
- package/dist/router/hooks.d.ts +18 -0
- package/dist/router/hooks.js +34 -0
- package/dist/router/index.d.ts +8 -0
- package/dist/router/index.js +7 -0
- package/dist/router/link.d.ts +305 -0
- package/dist/router/link.js +30 -0
- package/dist/router/match.d.ts +14 -0
- package/dist/router/match.js +27 -0
- package/dist/router/types.d.ts +55 -0
- package/dist/router/types.js +1 -0
- package/dist/types/index.d.ts +152 -0
- package/dist/types/index.js +1 -0
- 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
|
+
}
|