@sigil-dev/grimoire 0.7.6 → 0.7.7
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/index.ts +35 -34
- package/package.json +8 -6
- package/preload.js +3 -2
- package/server.ts +13 -13
- package/src/client/head.ts +29 -29
- package/src/client/router.ts +290 -224
- package/src/dev/compile-module.ts +173 -0
- package/src/dev/effect-registry.ts +23 -0
- package/src/dev/graph.ts +114 -0
- package/src/dev/hmr-client.ts +158 -0
- package/src/dev/hmr-server.ts +187 -0
- package/src/dev/loader.ts +47 -0
- package/src/dev/paths.ts +14 -0
- package/src/dev/runtime-bundle.ts +49 -0
- package/src/dev/watcher.ts +44 -0
- package/src/integrations/vite.ts +73 -72
- package/src/rendering/hydrate.ts +120 -81
- package/src/rendering/index.ts +296 -199
- package/src/rendering/ssrPlugin.ts +67 -53
- package/src/routing/manifest-gen.ts +42 -39
- package/src/routing/router.ts +109 -106
- package/src/routing/scanner.ts +141 -135
- package/src/routing/transform-routes.ts +101 -101
- package/src/server/build.ts +239 -147
- package/src/server/coordinator.ts +306 -306
- package/src/server/index.ts +260 -50
- package/src/server/worker.ts +59 -59
- package/src/typegen/index.ts +356 -353
- package/src/types.ts +270 -269
- package/test/context.test.ts +52 -52
- package/test/hydration.test.ts +119 -119
- package/test/middleware.test.ts +223 -223
- package/test/rendering.test.ts +579 -425
- package/test/routing.test.ts +81 -83
- package/test/scanning.test.ts +200 -181
- package/test/scope.test.ts +24 -8
- package/test/server.test.ts +249 -229
- package/test/streaming.test.ts +125 -106
- package/test/transform-routes.test.ts +84 -84
- package/test/typegen.test.ts +35 -25
- package/tsconfig.json +1 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, extname, join, relative } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import type { DevGraph } from "./graph";
|
|
5
|
+
import { normalizePath } from "./paths";
|
|
6
|
+
|
|
7
|
+
const RUNTIME_URL = "/__grimoire__/runtime.js";
|
|
8
|
+
const MODULES_BASE = "/__grimoire__/m/";
|
|
9
|
+
const DEPS_BASE = "/__grimoire__/dep/";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rewrite a single import specifier to a browser-servable URL.
|
|
13
|
+
* project-relative → /__grimoire__/m/...
|
|
14
|
+
* @sigil-dev/runtime → /__grimoire__/runtime.js
|
|
15
|
+
* bare npm → /__grimoire__/dep/...
|
|
16
|
+
* relative → /__grimoire__/m/... (resolved)
|
|
17
|
+
*/
|
|
18
|
+
function rewriteSpecifier(
|
|
19
|
+
specifier: string,
|
|
20
|
+
fromFile: string,
|
|
21
|
+
projectRoot: string,
|
|
22
|
+
): string {
|
|
23
|
+
if (specifier === "@sigil-dev/runtime") return RUNTIME_URL;
|
|
24
|
+
// if (specifier.startsWith("@sigil-dev/")) return RUNTIME_URL; // all sigil packages
|
|
25
|
+
|
|
26
|
+
// relative import — resolve to absolute, then make a module URL
|
|
27
|
+
if (specifier.startsWith(".")) {
|
|
28
|
+
const resolved = join(dirname(fromFile), specifier);
|
|
29
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
30
|
+
const candidate = resolved.endsWith(ext) ? resolved : resolved + ext;
|
|
31
|
+
if (existsSync(candidate)) {
|
|
32
|
+
const rel = relative(projectRoot, candidate).replace(/\\/g, "/");
|
|
33
|
+
return `${MODULES_BASE}${rel}.js`;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
// already has extension
|
|
37
|
+
const rel = relative(projectRoot, resolved).replace(/\\/g, "/");
|
|
38
|
+
return `${MODULES_BASE}${rel}.js`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// $lib alias
|
|
42
|
+
if (specifier.startsWith("$lib/")) {
|
|
43
|
+
const rel = specifier.replace("$lib/", "src/lib/");
|
|
44
|
+
// try common extensions
|
|
45
|
+
for (const ext of [".ts", ".tsx", ".js", ".jsx"]) {
|
|
46
|
+
const candidate = join(projectRoot, rel + ext);
|
|
47
|
+
if (existsSync(candidate)) {
|
|
48
|
+
return `${MODULES_BASE}${rel}${ext}.js`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return `${MODULES_BASE}${rel}.ts.js`; // fallback
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// $env — keep as-is, these are virtual modules handled elsewhere
|
|
55
|
+
if (specifier.startsWith("$env/")) return specifier;
|
|
56
|
+
|
|
57
|
+
// bare npm specifier → dep bundle
|
|
58
|
+
const pkgName = specifier.startsWith("@")
|
|
59
|
+
? specifier.split("/").slice(0, 2).join("/")
|
|
60
|
+
: specifier.split("/")[0];
|
|
61
|
+
return `${DEPS_BASE}${pkgName!.replace("/", "__")}.js`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Rewrite all import/export from specifiers in compiled JS output.
|
|
66
|
+
* Works on the static pattern `from "..."` — sufficient for Babel output.
|
|
67
|
+
*/
|
|
68
|
+
function rewriteImports(
|
|
69
|
+
code: string,
|
|
70
|
+
filePath: string,
|
|
71
|
+
projectRoot: string,
|
|
72
|
+
): string {
|
|
73
|
+
return code.replace(
|
|
74
|
+
/(from\s+|import\s+)(["'])([^"']+)\2/g,
|
|
75
|
+
(match, keyword, quote, specifier) => {
|
|
76
|
+
const rewritten = rewriteSpecifier(specifier, filePath, projectRoot);
|
|
77
|
+
return `${keyword}${quote}${rewritten}${quote}`;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface CompileResult {
|
|
83
|
+
js: string;
|
|
84
|
+
css: string | null;
|
|
85
|
+
cssHash: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function compileForBrowser(
|
|
89
|
+
filePath: string,
|
|
90
|
+
projectRoot: string,
|
|
91
|
+
): Promise<CompileResult> {
|
|
92
|
+
const source = await Bun.file(filePath).text();
|
|
93
|
+
|
|
94
|
+
// extract scoped CSS
|
|
95
|
+
const STYLE_RE = /<style[^>]*>([\s\S]*?)<\/style>/i;
|
|
96
|
+
const { computeHash, scopeCSS } = await import(
|
|
97
|
+
pathToFileURL(
|
|
98
|
+
join(
|
|
99
|
+
projectRoot,
|
|
100
|
+
"node_modules/@sigil-dev/compiler/src/babel/util/css.ts",
|
|
101
|
+
),
|
|
102
|
+
).href
|
|
103
|
+
);
|
|
104
|
+
const cssHash = computeHash(filePath);
|
|
105
|
+
const styleMatch = source.match(STYLE_RE);
|
|
106
|
+
const css = styleMatch ? scopeCSS(styleMatch[1].trim(), cssHash) : null;
|
|
107
|
+
const sourceNoStyle = source.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "");
|
|
108
|
+
|
|
109
|
+
// Babel transform — dom mode
|
|
110
|
+
const { transformSync } = await import("@babel/core");
|
|
111
|
+
const sigilPlugin = (await import("@sigil-dev/compiler/babel")).default;
|
|
112
|
+
const { createHash } = await import("node:crypto");
|
|
113
|
+
const hash = createHash("md5").update(filePath).digest("hex").slice(0, 8);
|
|
114
|
+
|
|
115
|
+
const babelResult = transformSync(sourceNoStyle, {
|
|
116
|
+
configFile: false,
|
|
117
|
+
babelrc: false,
|
|
118
|
+
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
119
|
+
plugins: [[sigilPlugin, { mode: "dom", hash }]],
|
|
120
|
+
filename: filePath,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
console.log(
|
|
124
|
+
"[compile] babel output first 500 chars:",
|
|
125
|
+
babelResult?.code?.slice(0, 500),
|
|
126
|
+
);
|
|
127
|
+
// strip TypeScript types
|
|
128
|
+
const transpiler = new Bun.Transpiler({ loader: "ts", target: "browser" });
|
|
129
|
+
let js = transpiler.transformSync(babelResult?.code ?? "");
|
|
130
|
+
|
|
131
|
+
const runtimeSpecifiers = [
|
|
132
|
+
"createSignal",
|
|
133
|
+
"createRawSignal",
|
|
134
|
+
"createEffect",
|
|
135
|
+
"createMemo",
|
|
136
|
+
"reconcile",
|
|
137
|
+
"claim",
|
|
138
|
+
"claimText",
|
|
139
|
+
"claimComment",
|
|
140
|
+
"hydrateKeyedList",
|
|
141
|
+
"insert",
|
|
142
|
+
"getHydrationNodes",
|
|
143
|
+
"pushHydrationNodes",
|
|
144
|
+
"popHydrationNodes",
|
|
145
|
+
"snapshot",
|
|
146
|
+
"tracking",
|
|
147
|
+
"createEffectPre",
|
|
148
|
+
"createInspectEffect",
|
|
149
|
+
"withEffectScope",
|
|
150
|
+
].join(", ");
|
|
151
|
+
|
|
152
|
+
js = js.replace(
|
|
153
|
+
/import\s*\{[^}]+\}\s*from\s*["']@sigil-dev\/runtime["'];?/,
|
|
154
|
+
`import { ${runtimeSpecifiers} } from "@sigil-dev/runtime";`,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// inject scoped CSS at runtime
|
|
158
|
+
if (css) {
|
|
159
|
+
//biome-ignore lint: bro shut up already
|
|
160
|
+
js =
|
|
161
|
+
`if (typeof document !== 'undefined' && !document.getElementById('sigil-${hash}')) {
|
|
162
|
+
const __s = document.createElement('style');
|
|
163
|
+
__s.id = 'sigil-${hash}';
|
|
164
|
+
__s.textContent = ${JSON.stringify(css)};
|
|
165
|
+
document.head.appendChild(__s);
|
|
166
|
+
}\n` + js;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// rewrite imports to browser-servable URLs
|
|
170
|
+
js = rewriteImports(js, filePath, projectRoot);
|
|
171
|
+
|
|
172
|
+
return { js, css, cssHash };
|
|
173
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { normalizePath } from "./paths";
|
|
2
|
+
|
|
3
|
+
const registry = new Map<string, (() => void)[]>();
|
|
4
|
+
|
|
5
|
+
export function registerModuleEffect(
|
|
6
|
+
filePath: string,
|
|
7
|
+
dispose: () => void,
|
|
8
|
+
): void {
|
|
9
|
+
const key = normalizePath(filePath);
|
|
10
|
+
let list = registry.get(key);
|
|
11
|
+
if (!list) registry.set(key, (list = []));
|
|
12
|
+
list.push(dispose);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function disposeModuleEffects(filePath: string): void {
|
|
16
|
+
const key = normalizePath(filePath);
|
|
17
|
+
for (const d of registry.get(key) ?? []) {
|
|
18
|
+
try {
|
|
19
|
+
d();
|
|
20
|
+
} catch {}
|
|
21
|
+
}
|
|
22
|
+
registry.delete(key);
|
|
23
|
+
}
|
package/src/dev/graph.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { RouteFile } from "../routing/scanner";
|
|
2
|
+
import { normalizePath } from "./paths";
|
|
3
|
+
|
|
4
|
+
export interface SpecRef {
|
|
5
|
+
start: number;
|
|
6
|
+
end: number;
|
|
7
|
+
target: string; // normalizePath key or dep name
|
|
8
|
+
kind: "project" | "dep" | "runtime";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ModNode {
|
|
12
|
+
key: string;
|
|
13
|
+
displayPath: string;
|
|
14
|
+
importers: Set<string>;
|
|
15
|
+
imports: Set<string>;
|
|
16
|
+
version: number;
|
|
17
|
+
compiled: {
|
|
18
|
+
js: string;
|
|
19
|
+
css: string | null;
|
|
20
|
+
cssHash: string;
|
|
21
|
+
specifiers: SpecRef[];
|
|
22
|
+
} | null;
|
|
23
|
+
isRoute: RouteFile | null;
|
|
24
|
+
lastSubstituted: Map<string, number>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class DevGraph {
|
|
28
|
+
private nodes = new Map<string, ModNode>();
|
|
29
|
+
|
|
30
|
+
get(key: string): ModNode | undefined {
|
|
31
|
+
return this.nodes.get(normalizePath(key));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
getOrCreate(filePath: string, displayPath?: string): ModNode {
|
|
35
|
+
const key = normalizePath(filePath);
|
|
36
|
+
if (!this.nodes.has(key)) {
|
|
37
|
+
this.nodes.set(key, {
|
|
38
|
+
key,
|
|
39
|
+
displayPath: displayPath ?? filePath,
|
|
40
|
+
importers: new Set(),
|
|
41
|
+
imports: new Set(),
|
|
42
|
+
version: 0,
|
|
43
|
+
compiled: null,
|
|
44
|
+
isRoute: null,
|
|
45
|
+
lastSubstituted: new Map(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return this.nodes.get(key)!;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
addEdge(importerPath: string, importedPath: string): void {
|
|
52
|
+
const a = normalizePath(importerPath);
|
|
53
|
+
const b = normalizePath(importedPath);
|
|
54
|
+
this.getOrCreate(a).imports.add(b);
|
|
55
|
+
this.getOrCreate(b).importers.add(a);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** DFS for cycles in the subgraph reachable from key via forward edges */
|
|
59
|
+
hasCycle(key: string): boolean {
|
|
60
|
+
const k = normalizePath(key);
|
|
61
|
+
const visited = new Set<string>();
|
|
62
|
+
const stack = new Set<string>();
|
|
63
|
+
const dfs = (node: string): boolean => {
|
|
64
|
+
if (stack.has(node)) return true;
|
|
65
|
+
if (visited.has(node)) return false;
|
|
66
|
+
visited.add(node);
|
|
67
|
+
stack.add(node);
|
|
68
|
+
for (const imp of this.nodes.get(node)?.imports ?? []) {
|
|
69
|
+
if (dfs(imp)) return true;
|
|
70
|
+
}
|
|
71
|
+
stack.delete(node);
|
|
72
|
+
return false;
|
|
73
|
+
};
|
|
74
|
+
return dfs(k);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Walk importers up to routes. Returns affected RouteFiles. */
|
|
78
|
+
affectedRoutes(changedKey: string): RouteFile[] {
|
|
79
|
+
const key = normalizePath(changedKey);
|
|
80
|
+
const seen = new Set<string>([key]);
|
|
81
|
+
const queue = [key];
|
|
82
|
+
const hits: RouteFile[] = [];
|
|
83
|
+
while (queue.length) {
|
|
84
|
+
const f = queue.pop()!;
|
|
85
|
+
const node = this.nodes.get(f);
|
|
86
|
+
if (!node) continue;
|
|
87
|
+
if (node.isRoute) {
|
|
88
|
+
hits.push(node.isRoute);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
for (const importer of node.importers) {
|
|
92
|
+
if (!seen.has(importer)) {
|
|
93
|
+
seen.add(importer);
|
|
94
|
+
queue.push(importer);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return hits;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
markRoute(filePath: string, route: RouteFile): void {
|
|
102
|
+
this.getOrCreate(filePath).isRoute = route;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
bump(filePath: string): number {
|
|
106
|
+
const node = this.getOrCreate(filePath);
|
|
107
|
+
node.version++;
|
|
108
|
+
return node.version;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
all(): IterableIterator<ModNode> {
|
|
112
|
+
return this.nodes.values();
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export const HMR_CLIENT_SOURCE = `
|
|
2
|
+
(function() {
|
|
3
|
+
let version = 0;
|
|
4
|
+
|
|
5
|
+
const staleRoutes = new Set();
|
|
6
|
+
|
|
7
|
+
async function applySwap(p) {
|
|
8
|
+
try {
|
|
9
|
+
const url = '/__grimoire__/m/' + p.modUrl + '?v=' + p.version;
|
|
10
|
+
const mod = await import(url);
|
|
11
|
+
|
|
12
|
+
// update route map — accessed via grimoire's initRouter export
|
|
13
|
+
if (window.__grimoire_routes__) {
|
|
14
|
+
window.__grimoire_routes__[p.pattern] = mod.default;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// re-render if currently on this pattern
|
|
18
|
+
const currentPattern = window.__grimoire_current_pattern__;
|
|
19
|
+
if (currentPattern === p.pattern && window.__grimoire_rerender__) {
|
|
20
|
+
await window.__grimoire_rerender__();
|
|
21
|
+
}
|
|
22
|
+
} catch (e) {
|
|
23
|
+
console.error('[sigil hmr] swap failed, reloading:', e);
|
|
24
|
+
location.reload();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function refetchCurrent() {
|
|
29
|
+
if (window.__grimoire_navigate__) {
|
|
30
|
+
await window.__grimoire_navigate__(location.pathname, true);
|
|
31
|
+
} else {
|
|
32
|
+
location.reload();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function applyCss(hash, text) {
|
|
37
|
+
let el = document.getElementById('sigil-' + hash);
|
|
38
|
+
if (!el) {
|
|
39
|
+
el = document.createElement('style');
|
|
40
|
+
el.id = 'sigil-' + hash;
|
|
41
|
+
document.head.appendChild(el);
|
|
42
|
+
}
|
|
43
|
+
el.textContent = text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function connect() {
|
|
47
|
+
const ws = new WebSocket(
|
|
48
|
+
(location.protocol === 'https:' ? 'wss' : 'ws') +
|
|
49
|
+
'://' + location.host + '/__grimoire__/hmr'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
ws.onopen = () => {
|
|
53
|
+
console.log('[sigil hmr] connected');
|
|
54
|
+
document.getElementById('__grimoire_overlay__')?.remove();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
ws.onmessage = async (ev) => {
|
|
58
|
+
const msg = JSON.parse(ev.data);
|
|
59
|
+
if (msg.type === 'error') { showOverlay(msg.message, msg.loc); return; }
|
|
60
|
+
if (msg.v !== undefined && msg.v <= version) return;
|
|
61
|
+
if (msg.v !== undefined) version = msg.v;
|
|
62
|
+
|
|
63
|
+
if (msg.type === 'batch') {
|
|
64
|
+
document.getElementById('__grimoire_overlay__')?.remove();
|
|
65
|
+
|
|
66
|
+
let hasReload = false;
|
|
67
|
+
let swapPayload = null;
|
|
68
|
+
const cssPayloads = [];
|
|
69
|
+
const stalePatterns = [];
|
|
70
|
+
|
|
71
|
+
for (const p of msg.payloads) {
|
|
72
|
+
if (p.kind === 'reload') { hasReload = true; break; }
|
|
73
|
+
if (p.kind === 'css') cssPayloads.push(p);
|
|
74
|
+
if (p.kind === 'swap') swapPayload = p;
|
|
75
|
+
if (p.kind === 'stale') stalePatterns.push(...p.patterns);
|
|
76
|
+
if (p.kind === 'invalidate') refetchCurrent();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (hasReload) { location.reload(); return; }
|
|
80
|
+
|
|
81
|
+
// apply CSS instantly
|
|
82
|
+
for (const p of cssPayloads) applyCss(p.hash, p.text);
|
|
83
|
+
|
|
84
|
+
// mark stale routes
|
|
85
|
+
for (const pattern of stalePatterns) staleRoutes.add(pattern);
|
|
86
|
+
|
|
87
|
+
// apply swap
|
|
88
|
+
if (swapPayload) {
|
|
89
|
+
await applySwap(swapPayload);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
ws.onclose = () => {
|
|
95
|
+
console.log('[sigil hmr] disconnected, reconnecting...');
|
|
96
|
+
const retry = setInterval(async () => {
|
|
97
|
+
try {
|
|
98
|
+
await fetch('/', { method: 'HEAD' });
|
|
99
|
+
clearInterval(retry);
|
|
100
|
+
location.reload();
|
|
101
|
+
} catch {}
|
|
102
|
+
}, 300);
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function stripAnsi(str) {
|
|
107
|
+
return str.replace(/\u001b[[0-9;]*m/g, '');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function unescapeHtml(str) {
|
|
111
|
+
let result = str;
|
|
112
|
+
// run twice to handle double-escaped entities
|
|
113
|
+
for (let i = 0; i < 2; i++) {
|
|
114
|
+
result = result
|
|
115
|
+
.replace(/&/g, '&')
|
|
116
|
+
.replace(/</g, '<')
|
|
117
|
+
.replace(/>/g, '>')
|
|
118
|
+
.replace(/"/g, '"')
|
|
119
|
+
.replace(/'/g, "'");
|
|
120
|
+
}
|
|
121
|
+
return result;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function showOverlay(message, loc) {
|
|
125
|
+
document.getElementById('__grimoire_overlay__')?.remove();
|
|
126
|
+
const clean = unescapeHtml(stripAnsi(message));
|
|
127
|
+
const d = document.createElement('div');
|
|
128
|
+
d.id = '__grimoire_overlay__';
|
|
129
|
+
d.style.cssText = [
|
|
130
|
+
'position:fixed;inset:0;z-index:99999',
|
|
131
|
+
'background:rgba(15,15,20,.94)',
|
|
132
|
+
'color:#ff8a8a',
|
|
133
|
+
'font:13px/1.6 monospace',
|
|
134
|
+
'padding:40px',
|
|
135
|
+
'overflow:auto',
|
|
136
|
+
'white-space:pre-wrap',
|
|
137
|
+
].join(';');
|
|
138
|
+
|
|
139
|
+
d.textContent = '[sigil] compile error\\n\\n' + clean;
|
|
140
|
+
|
|
141
|
+
if (loc) {
|
|
142
|
+
const link = document.createElement('a');
|
|
143
|
+
link.href = '/__grimoire__/open?file=' + encodeURIComponent(loc.file) +
|
|
144
|
+
'&line=' + loc.line + '&col=' + loc.col;
|
|
145
|
+
link.style.cssText = 'color:#88aaff;text-decoration:underline;cursor:pointer;display:block;margin:8px 0 16px';
|
|
146
|
+
link.textContent = loc.file + ':' + loc.line + ':' + loc.col;
|
|
147
|
+
link.onclick = (e) => { e.preventDefault(); fetch(link.href); };
|
|
148
|
+
d.appendChild(link);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
d.title = 'Click anywhere to dismiss';
|
|
152
|
+
d.onclick = (ev) => { if (ev.target === d) d.remove(); };
|
|
153
|
+
document.body.appendChild(d);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
connect();
|
|
157
|
+
})();
|
|
158
|
+
`;
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { ServerWebSocket } from "bun";
|
|
4
|
+
import { compileForBrowser } from "./compile-module";
|
|
5
|
+
import type { DevGraph } from "./graph";
|
|
6
|
+
import { normalizePath } from "./paths";
|
|
7
|
+
import { safeRead } from "./watcher";
|
|
8
|
+
|
|
9
|
+
export class HmrServer {
|
|
10
|
+
private clients = new Set<ServerWebSocket<any>>();
|
|
11
|
+
private v = 0;
|
|
12
|
+
get clientCount() {
|
|
13
|
+
return this.clients.size;
|
|
14
|
+
}
|
|
15
|
+
addClient(ws: ServerWebSocket<any>) {
|
|
16
|
+
this.clients.add(ws);
|
|
17
|
+
ws.send(JSON.stringify({ type: "connected" }));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
removeClient(ws: ServerWebSocket<any>) {
|
|
21
|
+
this.clients.delete(ws);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
send(msg: object) {
|
|
25
|
+
const s = JSON.stringify(msg);
|
|
26
|
+
for (const c of this.clients) c.send(s);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
batch(payloads: object[]) {
|
|
30
|
+
this.send({ type: "batch", payloads, v: ++this.v });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
reload() {
|
|
34
|
+
console.log("[sigil hmr] sending reload to", this.clients.size, "clients");
|
|
35
|
+
this.batch([{ kind: "reload" }]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
error(message: string, loc?: { file: string; line: number; col: number }) {
|
|
39
|
+
this.send({ type: "error", message, loc });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function extractLoc(
|
|
44
|
+
e: any,
|
|
45
|
+
filePath: string,
|
|
46
|
+
): { file: string; line: number; col: number } | undefined {
|
|
47
|
+
// Babel errors have e.loc.line / e.loc.column
|
|
48
|
+
if (e?.loc?.line) {
|
|
49
|
+
return {
|
|
50
|
+
file: filePath,
|
|
51
|
+
line: e.loc.line,
|
|
52
|
+
col: e.loc.column ?? 0,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// fallback: parse "file:line:col" from message
|
|
56
|
+
const m = String(e?.message ?? "").match(/:(\d+):(\d+)$/);
|
|
57
|
+
if (m) {
|
|
58
|
+
return {
|
|
59
|
+
file: filePath,
|
|
60
|
+
line: Number(m[1]),
|
|
61
|
+
col: Number(m[2]),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function compileAndSwap(
|
|
68
|
+
filePath: string,
|
|
69
|
+
graph: DevGraph,
|
|
70
|
+
hmr: HmrServer,
|
|
71
|
+
projectRoot: string,
|
|
72
|
+
routeTree: any,
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const key = normalizePath(filePath);
|
|
75
|
+
const node = graph.get(key);
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const result = await compileForBrowser(filePath, projectRoot);
|
|
79
|
+
|
|
80
|
+
// write to dev-modules cache
|
|
81
|
+
const devModulesDir = join(projectRoot, ".grimoire/dev-modules");
|
|
82
|
+
mkdirSync(devModulesDir, { recursive: true });
|
|
83
|
+
const { createHash } = await import("node:crypto");
|
|
84
|
+
const fileHash = createHash("md5")
|
|
85
|
+
.update(filePath)
|
|
86
|
+
.digest("hex")
|
|
87
|
+
.slice(0, 8);
|
|
88
|
+
const v = graph.bump(key);
|
|
89
|
+
const cachePath = join(devModulesDir, `${fileHash}_v${v}.js`);
|
|
90
|
+
writeFileSync(cachePath, result.js);
|
|
91
|
+
|
|
92
|
+
// store in graph node
|
|
93
|
+
if (node) {
|
|
94
|
+
node.compiled = {
|
|
95
|
+
js: result.js,
|
|
96
|
+
css: result.css,
|
|
97
|
+
cssHash: result.cssHash,
|
|
98
|
+
specifiers: [], // populated in Step 4
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// find affected route pattern
|
|
103
|
+
const affectedRoutes = graph.affectedRoutes(key);
|
|
104
|
+
const routeFile = node?.isRoute ?? affectedRoutes[0] ?? null;
|
|
105
|
+
|
|
106
|
+
if (!routeFile) {
|
|
107
|
+
// no route found — fall back to reload
|
|
108
|
+
console.log("[sigil hmr] no route affected, reloading");
|
|
109
|
+
hmr.reload();
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// client-relative path for the module URL
|
|
114
|
+
const clientPath = relative(projectRoot, filePath).replace(/\\/g, "/");
|
|
115
|
+
const modUrl = `${clientPath}.js`;
|
|
116
|
+
|
|
117
|
+
console.log("[sigil hmr] compiled:", filePath, `v=${v}`);
|
|
118
|
+
|
|
119
|
+
const payloads: object[] = [];
|
|
120
|
+
|
|
121
|
+
// CSS payload if style changed
|
|
122
|
+
if (result.css) {
|
|
123
|
+
payloads.push({
|
|
124
|
+
kind: "css",
|
|
125
|
+
hash: result.cssHash,
|
|
126
|
+
text: result.css,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// swap payload
|
|
131
|
+
payloads.push({
|
|
132
|
+
kind: "swap",
|
|
133
|
+
pattern: routeFile.path,
|
|
134
|
+
modUrl,
|
|
135
|
+
version: v,
|
|
136
|
+
cachePath: `${fileHash}_v${v}.js`, // server-side cache filename
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// stale payloads for other affected routes
|
|
140
|
+
const otherRoutes = affectedRoutes.filter((r) => r !== routeFile);
|
|
141
|
+
if (otherRoutes.length > 0) {
|
|
142
|
+
payloads.push({
|
|
143
|
+
kind: "stale",
|
|
144
|
+
patterns: otherRoutes.map((r) => r.path),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
console.log(
|
|
148
|
+
"[sigil hmr] sending batch:",
|
|
149
|
+
JSON.stringify(payloads, null, 2),
|
|
150
|
+
);
|
|
151
|
+
hmr.batch(payloads);
|
|
152
|
+
} catch (e: any) {
|
|
153
|
+
const loc = extractLoc(e, filePath);
|
|
154
|
+
console.error("[sigil hmr] compile error:", e.message);
|
|
155
|
+
hmr.error(e.message ?? String(e), loc);
|
|
156
|
+
// no bump — old version stays live
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function handleChange(
|
|
161
|
+
filePath: string,
|
|
162
|
+
graph: DevGraph,
|
|
163
|
+
hmr: HmrServer,
|
|
164
|
+
srcDir: string,
|
|
165
|
+
projectRoot: string,
|
|
166
|
+
routeTree: any,
|
|
167
|
+
): Promise<void> {
|
|
168
|
+
const key = normalizePath(filePath);
|
|
169
|
+
const source = await safeRead(filePath);
|
|
170
|
+
if (!source) {
|
|
171
|
+
hmr.reload();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (graph.hasCycle(key)) {
|
|
175
|
+
hmr.reload();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const node = graph.get(key);
|
|
179
|
+
if (!node) {
|
|
180
|
+
console.log("[sigil hmr] unknown file, reloading:", filePath);
|
|
181
|
+
hmr.reload();
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
console.log("[sigil hmr] changed:", filePath);
|
|
186
|
+
await compileAndSwap(filePath, graph, hmr, projectRoot, routeTree);
|
|
187
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { disposeModuleEffects } from "./effect-registry";
|
|
5
|
+
import type { DevGraph } from "./graph";
|
|
6
|
+
import { normalizePath } from "./paths";
|
|
7
|
+
|
|
8
|
+
export function makeDevLoader(graph: DevGraph, projectRoot: string) {
|
|
9
|
+
const cacheDir = join(projectRoot, ".grimoire/dev-cache");
|
|
10
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
11
|
+
|
|
12
|
+
return async function devLoadModule(filePath: string): Promise<any> {
|
|
13
|
+
const key = normalizePath(filePath);
|
|
14
|
+
disposeModuleEffects(key);
|
|
15
|
+
const v = graph.get(key)?.version ?? 0;
|
|
16
|
+
|
|
17
|
+
if (v === 0) {
|
|
18
|
+
// first load — let Bun handle it normally via SSR plugin
|
|
19
|
+
return import(filePath);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// versioned load — read the source, let the SSR plugin transform it,
|
|
23
|
+
// write transformed output to a unique cache path, import that
|
|
24
|
+
// We can't re-trigger the plugin, so we invoke the compile pipeline directly
|
|
25
|
+
const source = await Bun.file(filePath).text();
|
|
26
|
+
const { transformSync } = await import("@babel/core");
|
|
27
|
+
const sigilPlugin = (await import("@sigil-dev/compiler/babel")).default;
|
|
28
|
+
const { createHash } = await import("node:crypto");
|
|
29
|
+
const hash = createHash("md5").update(filePath).digest("hex").slice(0, 8);
|
|
30
|
+
|
|
31
|
+
const result = transformSync(source, {
|
|
32
|
+
configFile: false,
|
|
33
|
+
babelrc: false,
|
|
34
|
+
parserOpts: { plugins: ["typescript", "jsx"] },
|
|
35
|
+
plugins: [[sigilPlugin, { mode: "ssr", hash }]],
|
|
36
|
+
filename: filePath,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
const transpiler = new Bun.Transpiler({ loader: "ts", target: "bun" });
|
|
40
|
+
const compiled = transpiler.transformSync(result?.code ?? "");
|
|
41
|
+
|
|
42
|
+
const cachePath = join(cacheDir, `${hash}_v${v}.js`);
|
|
43
|
+
writeFileSync(cachePath, compiled);
|
|
44
|
+
|
|
45
|
+
return import(pathToFileURL(cachePath).href);
|
|
46
|
+
};
|
|
47
|
+
}
|