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.
- package/client.d.ts +7 -15
- package/dist/commands/build.js +1 -1
- package/dist/commands/dev.js +12 -29
- 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 +18 -16
- 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/load-config.d.ts +1 -1
- package/dist/paths.d.ts +1 -1
- 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 +56 -0
- package/dist/plugin/emit-assets.js +47 -0
- package/dist/plugin/generators/html.d.ts +64 -0
- package/dist/plugin/generators/html.js +167 -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 +69 -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 +39 -7
|
@@ -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
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function readJson<T = unknown>(file: string, root: string): T | null;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export {};
|
|
2
|
+
declare global {
|
|
3
|
+
interface ImportMetaEnv {
|
|
4
|
+
readonly MODE: string;
|
|
5
|
+
readonly DEV: boolean;
|
|
6
|
+
readonly PROD: boolean;
|
|
7
|
+
readonly BASE_URL: string;
|
|
8
|
+
readonly [key: `EXTRO_PUBLIC_${string}`]: string | undefined;
|
|
9
|
+
}
|
|
10
|
+
interface ImportMeta {
|
|
11
|
+
readonly env: ImportMetaEnv;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ComponentType, ReactElement } from "react";
|
|
2
|
+
import type { BoundaryKind } from "../types/index.js";
|
|
3
|
+
import type { Router } from "./context.js";
|
|
4
|
+
import type { ErrorProps, LayoutProps, PageProps } from "./types.js";
|
|
5
|
+
/** A boundary already paired with its loaded component (orchestration zips this). */
|
|
6
|
+
export type ResolvedBoundary = {
|
|
7
|
+
kind: BoundaryKind;
|
|
8
|
+
component: ComponentType<LayoutProps> | ComponentType<ErrorProps>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Every renderable outcome of a navigation. `buildTree` is total over this:
|
|
12
|
+
* `render()` resolves one of these (with the navToken/load orchestration) and
|
|
13
|
+
* hands it here; structure lives nowhere else.
|
|
14
|
+
*/
|
|
15
|
+
export type RenderOutcome = {
|
|
16
|
+
type: "match";
|
|
17
|
+
page: ComponentType<PageProps>;
|
|
18
|
+
params: Record<string, string>;
|
|
19
|
+
boundaries: ResolvedBoundary[];
|
|
20
|
+
} | {
|
|
21
|
+
type: "not-found";
|
|
22
|
+
notFound: ComponentType;
|
|
23
|
+
rootLayout: ComponentType<LayoutProps> | null;
|
|
24
|
+
} | {
|
|
25
|
+
type: "load-error";
|
|
26
|
+
error: Error;
|
|
27
|
+
reset: () => void;
|
|
28
|
+
};
|
|
29
|
+
export type RenderContext = {
|
|
30
|
+
pathname: string;
|
|
31
|
+
search: string;
|
|
32
|
+
router: Router;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* @describe The single, pure home of ADR 0003 §3/§4/§5 structure. Given a
|
|
36
|
+
* resolved navigation outcome, returns the React element tree to mount:
|
|
37
|
+
*
|
|
38
|
+
* - match: <Provider><BuiltInEB> L0 <EB user> ... <Page/> ... </Provider>
|
|
39
|
+
* (each segment's error nested inside its sibling layout, §3)
|
|
40
|
+
* - not-found: <Provider><BuiltInEB> rootLayout? <NotFound/> </Provider> (§4)
|
|
41
|
+
* - load-error: bare <DefaultError/> (load failed outside React render, §5)
|
|
42
|
+
*
|
|
43
|
+
* Pure and total: same inputs, same tree; no DOM, no effects. The always-on
|
|
44
|
+
* built-in error boundary (§5) means a match/not-found surface never blanks.
|
|
45
|
+
*/
|
|
46
|
+
export declare function buildTree(outcome: RenderOutcome, ctx: RenderContext): ReactElement;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { RouterContext } from "./context.js";
|
|
3
|
+
import { ErrorBoundary } from "./error-boundary.js";
|
|
4
|
+
import { DefaultError } from "./defaults.js";
|
|
5
|
+
/**
|
|
6
|
+
* @describe The single, pure home of ADR 0003 §3/§4/§5 structure. Given a
|
|
7
|
+
* resolved navigation outcome, returns the React element tree to mount:
|
|
8
|
+
*
|
|
9
|
+
* - match: <Provider><BuiltInEB> L0 <EB user> ... <Page/> ... </Provider>
|
|
10
|
+
* (each segment's error nested inside its sibling layout, §3)
|
|
11
|
+
* - not-found: <Provider><BuiltInEB> rootLayout? <NotFound/> </Provider> (§4)
|
|
12
|
+
* - load-error: bare <DefaultError/> (load failed outside React render, §5)
|
|
13
|
+
*
|
|
14
|
+
* Pure and total: same inputs, same tree; no DOM, no effects. The always-on
|
|
15
|
+
* built-in error boundary (§5) means a match/not-found surface never blanks.
|
|
16
|
+
*/
|
|
17
|
+
export function buildTree(outcome, ctx) {
|
|
18
|
+
if (outcome.type === "load-error") {
|
|
19
|
+
return createElement(DefaultError, {
|
|
20
|
+
error: outcome.error,
|
|
21
|
+
reset: outcome.reset,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
// Router context + the always-on outermost built-in error boundary (§5),
|
|
25
|
+
// shared by the match and not-found paths so they stay consistent.
|
|
26
|
+
const provide = (params, inner) => createElement(RouterContext.Provider, {
|
|
27
|
+
value: {
|
|
28
|
+
pathname: ctx.pathname,
|
|
29
|
+
search: ctx.search,
|
|
30
|
+
params,
|
|
31
|
+
router: ctx.router,
|
|
32
|
+
},
|
|
33
|
+
}, createElement(ErrorBoundary, { fallback: DefaultError, children: inner }));
|
|
34
|
+
if (outcome.type === "not-found") {
|
|
35
|
+
let inner = createElement(outcome.notFound);
|
|
36
|
+
if (outcome.rootLayout) {
|
|
37
|
+
inner = createElement(outcome.rootLayout, { children: inner });
|
|
38
|
+
}
|
|
39
|
+
return provide({}, inner);
|
|
40
|
+
}
|
|
41
|
+
// Fold innermost-first so the outermost boundary wraps everything; each
|
|
42
|
+
// segment's error sits inside its sibling layout (the chain is ordered
|
|
43
|
+
// layout-before-error per segment). Empty chain = just the page.
|
|
44
|
+
const composed = outcome.boundaries.reduceRight((child, boundary) => {
|
|
45
|
+
if (boundary.kind === "error") {
|
|
46
|
+
return createElement(ErrorBoundary, {
|
|
47
|
+
fallback: boundary.component,
|
|
48
|
+
children: child,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
return createElement(boundary.component, {
|
|
52
|
+
children: child,
|
|
53
|
+
});
|
|
54
|
+
}, createElement(outcome.page, { params: outcome.params }));
|
|
55
|
+
return provide(outcome.params, composed);
|
|
56
|
+
}
|