extrojs 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/client.d.ts +7 -15
  2. package/dist/commands/build.js +1 -1
  3. package/dist/commands/dev.js +12 -29
  4. package/dist/config.d.ts +2 -2
  5. package/dist/core/asset.d.ts +10 -0
  6. package/dist/core/asset.js +10 -0
  7. package/dist/dev-assets.d.ts +3 -3
  8. package/dist/dev-assets.js +18 -16
  9. package/dist/exports/asset.d.ts +1 -0
  10. package/dist/exports/asset.js +1 -0
  11. package/dist/exports/link.d.ts +1 -0
  12. package/dist/exports/link.js +1 -0
  13. package/dist/exports/navigation.d.ts +2 -0
  14. package/dist/exports/navigation.js +1 -0
  15. package/dist/exports/runtime.d.ts +2 -0
  16. package/dist/exports/runtime.js +3 -0
  17. package/dist/load-config.d.ts +1 -1
  18. package/dist/paths.d.ts +1 -1
  19. package/dist/plugin/app-tree.d.ts +59 -0
  20. package/dist/plugin/app-tree.js +214 -0
  21. package/dist/plugin/asset-inventory.d.ts +24 -0
  22. package/dist/plugin/asset-inventory.js +9 -0
  23. package/dist/plugin/dev-reactions.d.ts +59 -0
  24. package/dist/plugin/dev-reactions.js +62 -0
  25. package/dist/plugin/emit-assets.d.ts +56 -0
  26. package/dist/plugin/emit-assets.js +47 -0
  27. package/dist/plugin/generators/html.d.ts +64 -0
  28. package/dist/plugin/generators/html.js +167 -0
  29. package/dist/plugin/generators/icons.d.ts +15 -0
  30. package/dist/plugin/generators/icons.js +16 -0
  31. package/dist/plugin/generators/public.d.ts +17 -0
  32. package/dist/plugin/generators/public.js +20 -0
  33. package/dist/plugin/icons.d.ts +5 -0
  34. package/dist/plugin/icons.js +20 -0
  35. package/dist/plugin/index.d.ts +31 -0
  36. package/dist/plugin/index.js +246 -0
  37. package/dist/plugin/internal.d.ts +14 -0
  38. package/dist/plugin/internal.js +6 -0
  39. package/dist/plugin/manifest.d.ts +29 -0
  40. package/dist/plugin/manifest.js +68 -0
  41. package/dist/plugin/public.d.ts +21 -0
  42. package/dist/plugin/public.js +69 -0
  43. package/dist/plugin/runtimes/clients/csui-mount.js +90 -0
  44. package/dist/plugin/runtimes/clients/dev-bridge.js +194 -0
  45. package/dist/plugin/runtimes/csui-mount.d.ts +18 -0
  46. package/dist/plugin/runtimes/csui-mount.js +21 -0
  47. package/dist/plugin/runtimes/dev-bridge.d.ts +22 -0
  48. package/dist/plugin/runtimes/dev-bridge.js +19 -0
  49. package/dist/plugin/runtimes/routes-module.d.ts +20 -0
  50. package/dist/plugin/runtimes/routes-module.js +51 -0
  51. package/dist/plugin/runtimes/runtime-module.d.ts +16 -0
  52. package/dist/plugin/runtimes/runtime-module.js +40 -0
  53. package/dist/plugin/surfaces.d.ts +37 -0
  54. package/dist/plugin/surfaces.js +67 -0
  55. package/dist/plugin/types/index.d.ts +9 -0
  56. package/dist/plugin/types/index.js +1 -0
  57. package/dist/plugin/utils/read-json.d.ts +1 -0
  58. package/dist/plugin/utils/read-json.js +8 -0
  59. package/dist/react/env.d.ts +13 -0
  60. package/dist/react/env.js +1 -0
  61. package/dist/router/build-tree.d.ts +46 -0
  62. package/dist/router/build-tree.js +56 -0
  63. package/dist/router/context.d.ts +13 -0
  64. package/dist/router/context.js +2 -0
  65. package/dist/router/create-router.d.ts +10 -0
  66. package/dist/router/create-router.js +126 -0
  67. package/dist/router/defaults.d.ts +24 -0
  68. package/dist/router/defaults.js +25 -0
  69. package/dist/router/error-boundary.d.ts +23 -0
  70. package/dist/router/error-boundary.js +21 -0
  71. package/dist/router/hooks.d.ts +18 -0
  72. package/dist/router/hooks.js +34 -0
  73. package/dist/router/index.d.ts +8 -0
  74. package/dist/router/index.js +7 -0
  75. package/dist/router/link.d.ts +305 -0
  76. package/dist/router/link.js +30 -0
  77. package/dist/router/match.d.ts +14 -0
  78. package/dist/router/match.js +27 -0
  79. package/dist/router/types.d.ts +55 -0
  80. package/dist/router/types.js +1 -0
  81. package/dist/types/index.d.ts +152 -0
  82. package/dist/types/index.js +1 -0
  83. package/package.json +39 -7
@@ -0,0 +1,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,9 @@
1
+ export type { ManifestV3 } from "../../types/index.js";
2
+ export type PluginContextLike = {
3
+ emitFile(file: {
4
+ type: "asset";
5
+ fileName: string;
6
+ source: string | Uint8Array;
7
+ }): string;
8
+ warn(message: string): void;
9
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export declare function readJson<T = unknown>(file: string, root: string): T | null;
@@ -0,0 +1,8 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ export function readJson(file, root) {
4
+ const filePath = path.join(root, file);
5
+ if (!fs.existsSync(filePath))
6
+ return null;
7
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
8
+ }
@@ -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
+ }