@ilha/router 0.11.3 → 0.11.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -66
- package/dist/codegen.d.ts +3 -3
- package/dist/hash.d.ts +13 -9
- package/dist/head-BLtm0xtK.js +342 -0
- package/dist/head.d.ts +42 -28
- package/dist/http.d.ts +1 -1
- package/dist/index.d.ts +52 -55
- package/dist/index.js +339 -305
- package/dist/plugin-DCdQ9yO8.js +1140 -0
- package/dist/plugin.d.ts +15 -15
- package/dist/request-scope.d.ts +3 -5
- package/dist/route-error.d.ts +6 -0
- package/dist/route-match.d.ts +3 -3
- package/dist/rsbuild.d.ts +3 -3
- package/dist/rsbuild.js +2 -4
- package/dist/server-island.d.ts +26 -15
- package/dist/server-island.js +166 -136
- package/dist/server-islands.d.ts +15 -14
- package/dist/snapshot-XpAkSV8s.js +57 -0
- package/dist/snapshot.d.ts +15 -2
- package/dist/{ssr-CV_vNpS3.js → ssr-Dvq_qXs4.js} +170 -142
- package/dist/ssr.d.ts +38 -39
- package/dist/ssr.js +2 -2
- package/dist/vite.d.ts +3 -3
- package/dist/vite.js +2 -4
- package/oxlint.cjs +201 -122
- package/package.json +3 -3
- package/dist/head-DpYV3bHH.js +0 -285
- package/dist/plugin-Bfnn44nu.js +0 -1034
- package/dist/snapshot-C0E2OGwL.js +0 -55
package/dist/plugin-Bfnn44nu.js
DELETED
|
@@ -1,1034 +0,0 @@
|
|
|
1
|
-
import { C as runWithIslandRequest, a as authorizeFrameRequest, b as setFrameAuth, c as frameScopedUrl, f as isSafeFramePath, h as parseFrameProps, n as FrameError, s as frameEnvelope, u as getFrameGuard, x as setFrameGuard, y as renderServerIslandResult } from "./ssr-CV_vNpS3.js";
|
|
2
|
-
import { existsSync, readFileSync, statSync, watch } from "node:fs";
|
|
3
|
-
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
-
import * as Result from "effect/Result";
|
|
5
|
-
import { createUnplugin } from "unplugin";
|
|
6
|
-
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
|
|
9
|
-
//#region src/server-islands.ts
|
|
10
|
-
/**
|
|
11
|
-
* Build-time support for server-defined islands. The plugin scans
|
|
12
|
-
* `*.server.ts(x)` modules for island exports (`export const X = ilha…`),
|
|
13
|
-
* generates a client virtual module per file that re-creates those exports as
|
|
14
|
-
* proxies wired to tacho stubs, and rewrites client-graph import sites so
|
|
15
|
-
* island bindings resolve to the proxy while everything else keeps flowing
|
|
16
|
-
* through oxidejs's tacho stub replacement.
|
|
17
|
-
*/
|
|
18
|
-
/** Oxide RPC client key — basename without `.server.*` (oxide indexes by module name). */
|
|
19
|
-
function serverModuleRpcKey(spec) {
|
|
20
|
-
return basename(spec).replace(/\.server\.(?:[jt]sx?)$/i, "");
|
|
21
|
-
}
|
|
22
|
-
/** Client repaint group key — full normalized path so same-basename files stay distinct. */
|
|
23
|
-
function serverModuleRepaintKey(spec) {
|
|
24
|
-
return spec.replace(/\\/g, "/").replace(/\.server\.(?:[jt]sx?)$/i, "");
|
|
25
|
-
}
|
|
26
|
-
/**
|
|
27
|
-
* Replace identity `action(` wrappers of exported server actions with the
|
|
28
|
-
* capture-aware shim on ALREADY-COMPILED module code. Must run inside the
|
|
29
|
-
* SSR transform so upstream JSX/TS output is preserved.
|
|
30
|
-
*/
|
|
31
|
-
function rewriteServerActions(code, rpcActions) {
|
|
32
|
-
const names = Object.keys(rpcActions);
|
|
33
|
-
if (names.length === 0) return code;
|
|
34
|
-
const re = new RegExp(`(export\\s+(?:const|let|var)\\s+(${names.join("|")})\\b\\s*=\\s*)action\\s*\\(`, "g");
|
|
35
|
-
return code.replace(re, (_match, head, name) => {
|
|
36
|
-
const key = rpcActions[name];
|
|
37
|
-
return `${head}__ilhaServerAction(${JSON.stringify(key)}, `;
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
const EXPORT_RE = /(?:^|\n)\s*export\s+(?:declare\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
|
|
41
|
-
const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\s+function|function\s*\*)/g;
|
|
42
|
-
const DEFAULT_ISLAND_RE = /export\s+default\s+(?:async\s+function|function\s*\*|function\b)/;
|
|
43
|
-
function clientRefPublicId(spec, imported) {
|
|
44
|
-
return createHash("sha256").update(`${spec}#${imported}`).digest("base64url");
|
|
45
|
-
}
|
|
46
|
-
function scanClientRefs(source) {
|
|
47
|
-
const used = new Set(Array.from(source.matchAll(/<([A-Z][\w$]*)\b/g), (match) => match[1]));
|
|
48
|
-
const refs = [];
|
|
49
|
-
for (const match of source.matchAll(/(?:^|\n)\s*import\s+(?!type\b)([^'"\n]+?)\s+from\s+["']([^"']+)["']/g)) {
|
|
50
|
-
const clause = match[1].trim();
|
|
51
|
-
const spec = match[2];
|
|
52
|
-
const brace = clause.match(/\{([^}]+)\}/)?.[1];
|
|
53
|
-
if (brace) for (const part of brace.split(",")) {
|
|
54
|
-
const binding = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
55
|
-
if (!binding) continue;
|
|
56
|
-
const imported = binding[1];
|
|
57
|
-
const local = binding[2] ?? imported;
|
|
58
|
-
if (used.has(local)) refs.push({
|
|
59
|
-
id: clientRefPublicId(spec, imported),
|
|
60
|
-
local,
|
|
61
|
-
imported,
|
|
62
|
-
spec
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
const defaultLocal = clause.split(",", 1)[0].trim();
|
|
66
|
-
if (/^[A-Za-z_$][\w$]*$/.test(defaultLocal) && used.has(defaultLocal)) refs.push({
|
|
67
|
-
id: clientRefPublicId(spec, "default"),
|
|
68
|
-
local: defaultLocal,
|
|
69
|
-
imported: "default",
|
|
70
|
-
spec
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
return refs;
|
|
74
|
-
}
|
|
75
|
-
/** Extract the balanced-paren argument list starting at the "(" following
|
|
76
|
-
* `from` index. String literals are skipped so parens inside them don't count.
|
|
77
|
-
* Returns the inner text, or null when unbalanced within `limit` chars. */
|
|
78
|
-
function extractCallArgs(source, openParen, limit = 4e3) {
|
|
79
|
-
let depth = 0;
|
|
80
|
-
let quote = null;
|
|
81
|
-
for (let i = openParen; i < Math.min(source.length, openParen + limit); i++) {
|
|
82
|
-
const ch = source[i];
|
|
83
|
-
if (quote !== null) {
|
|
84
|
-
if (ch === "\\") i++;
|
|
85
|
-
else if (ch === quote) quote = null;
|
|
86
|
-
continue;
|
|
87
|
-
}
|
|
88
|
-
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
89
|
-
quote = ch;
|
|
90
|
-
continue;
|
|
91
|
-
}
|
|
92
|
-
if (ch === "(") depth++;
|
|
93
|
-
else if (ch === ")") {
|
|
94
|
-
depth--;
|
|
95
|
-
if (depth === 0) return source.slice(openParen + 1, i);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
console.warn("[ilha-router] scanServerIslands: argument list exceeded the scan limit — call skipped.");
|
|
99
|
-
return null;
|
|
100
|
-
}
|
|
101
|
-
function referencedExports(body, candidates) {
|
|
102
|
-
for (const match of body.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) if (candidates.has(match[1])) return match[1];
|
|
103
|
-
}
|
|
104
|
-
/** Scan a `*.server.ts(x)` module source for island exports and their
|
|
105
|
-
* declarative wiring. Convention: islands start with `ilha` — both builder
|
|
106
|
-
* function components (`ilha(() => …)`) and `ilha(schema, component)`. */
|
|
107
|
-
function scanServerIslands(source) {
|
|
108
|
-
const exports = [];
|
|
109
|
-
for (const match of source.matchAll(EXPORT_RE)) exports.push(match[1]);
|
|
110
|
-
for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of match[1].split(",")) {
|
|
111
|
-
const parts2 = part.trim().split(/\s+as\s+/);
|
|
112
|
-
const name = (parts2.length === 2 ? parts2[1] : parts2[0])?.trim();
|
|
113
|
-
if (name && /^[A-Za-z_$][\w$]*$/.test(name) && !exports.includes(name)) exports.push(name);
|
|
114
|
-
}
|
|
115
|
-
const candidates = new Set(exports);
|
|
116
|
-
const rpcActions = {};
|
|
117
|
-
for (const match of source.matchAll(/export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*action\s*\(/g)) rpcActions[match[1]] = `x:${match[1]}`;
|
|
118
|
-
const islands = [];
|
|
119
|
-
const collect = (name, start, sliceEnd) => {
|
|
120
|
-
const slice = source.slice(start, sliceEnd);
|
|
121
|
-
const as = "div";
|
|
122
|
-
const streams = {};
|
|
123
|
-
const actions = {};
|
|
124
|
-
let actionOrder = 0;
|
|
125
|
-
let streamOrder = 0;
|
|
126
|
-
for (const match of slice.matchAll(/[A-Za-z0-9_$]*[Aa]ction\s*\(/g)) {
|
|
127
|
-
const args = extractCallArgs(slice, match.index + match[0].indexOf("("));
|
|
128
|
-
if (!args) continue;
|
|
129
|
-
const target = referencedExports(args, candidates);
|
|
130
|
-
actions[`a${actionOrder++}`] = target ?? "";
|
|
131
|
-
}
|
|
132
|
-
for (const match of slice.matchAll(/fromAsyncIterable\(\s*([A-Za-z_$][\w$]*)\s*\(/g)) {
|
|
133
|
-
const target = match[1];
|
|
134
|
-
if (candidates.has(target) && !Object.values(streams).includes(target)) streams[`d${streamOrder++}`] = target;
|
|
135
|
-
}
|
|
136
|
-
islands.push({
|
|
137
|
-
name,
|
|
138
|
-
as,
|
|
139
|
-
streams,
|
|
140
|
-
actions
|
|
141
|
-
});
|
|
142
|
-
};
|
|
143
|
-
for (const match of source.matchAll(ISLAND_EXPORT_RE)) {
|
|
144
|
-
const name = match[1];
|
|
145
|
-
const rest = source.slice((match.index ?? 0) + match[0].length);
|
|
146
|
-
const nextExport = rest.search(/\nexport\b/);
|
|
147
|
-
collect(name, match.index ?? 0, (match.index ?? 0) + match[0].length + (nextExport === -1 ? rest.length : nextExport));
|
|
148
|
-
}
|
|
149
|
-
const defaultMatch = source.match(DEFAULT_ISLAND_RE);
|
|
150
|
-
if (defaultMatch && defaultMatch.index !== void 0) {
|
|
151
|
-
const rest = source.slice(defaultMatch.index + defaultMatch[0].length);
|
|
152
|
-
const nextExport = rest.search(/\nexport\b/);
|
|
153
|
-
collect("default", defaultMatch.index, defaultMatch.index + defaultMatch[0].length + (nextExport === -1 ? rest.length : nextExport));
|
|
154
|
-
}
|
|
155
|
-
return {
|
|
156
|
-
islands,
|
|
157
|
-
exports,
|
|
158
|
-
rpcActions,
|
|
159
|
-
clientRefs: scanClientRefs(source)
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
function loadServerModuleScan(path) {
|
|
163
|
-
return scanServerIslands(readFileSync(path, "utf8"));
|
|
164
|
-
}
|
|
165
|
-
/** Virtual-module id prefix for generated client proxies of server islands.
|
|
166
|
-
* The file path rides base64url-encoded: a raw suffix like
|
|
167
|
-
* `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
|
|
168
|
-
* loader would claim the virtual module before us. */
|
|
169
|
-
const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
|
|
170
|
-
/** Virtual-module specifier serving the client proxy for one server island file. */
|
|
171
|
-
function serverIslandVirtualSpec(file) {
|
|
172
|
-
return SERVER_ISLAND_PREFIX + Buffer.from(file).toString("base64url");
|
|
173
|
-
}
|
|
174
|
-
/** Emit the client virtual module for one scanned server file. Plain JS —
|
|
175
|
-
* `\0` virtual modules bypass Vite's built-in TS transform, so type-only
|
|
176
|
-
* constructs here would reach the browser unparsed. Editor types are
|
|
177
|
-
* unaffected: TS resolves the ORIGINAL specifier (the real server module);
|
|
178
|
-
* this module exists only inside the client bundle. Frames are fetched from
|
|
179
|
-
* the plugin's `/__ilha/frame` dev middleware. */
|
|
180
|
-
function serverIslandPublicId(spec, name) {
|
|
181
|
-
return createHash("sha256").update(`${spec}#${name}`).digest("base64url");
|
|
182
|
-
}
|
|
183
|
-
function generateServerIslandModule(spec, scan) {
|
|
184
|
-
const rpcKey = serverModuleRpcKey(spec);
|
|
185
|
-
const repaintKey = serverModuleRepaintKey(spec);
|
|
186
|
-
const lines = [
|
|
187
|
-
`import { client as $$rpc } from "virtual:oxide/client";`,
|
|
188
|
-
`import { __ilhaApplyHead, __ilhaServerIsland } from "@ilha/router/server-island";`,
|
|
189
|
-
`const $$call = (method, args) => { const opts = args.at(-1); return opts && typeof opts === "object" && opts.signal instanceof AbortSignal && Object.keys(opts).length === 1 ? $$rpc[${JSON.stringify(rpcKey)}][method](...args.slice(0, -1), opts) : $$rpc[${JSON.stringify(rpcKey)}][method](...args); };`,
|
|
190
|
-
...scan.clientRefs.map((ref, index) => ref.imported === "default" ? `import $$child${index} from ${JSON.stringify(ref.spec)};` : `import { ${ref.imported} as $$child${index} } from ${JSON.stringify(ref.spec)};`)
|
|
191
|
-
];
|
|
192
|
-
for (const name of scan.exports) if (!scan.islands.some((island) => island.name === name)) lines.push(`export const ${name} = (...args) => $$call(${JSON.stringify(name)}, args);`);
|
|
193
|
-
for (const island of scan.islands) {
|
|
194
|
-
const wiring = [];
|
|
195
|
-
const streams = Object.entries(island.streams).map(([key, target]) => `${JSON.stringify(key)}: (signal) => $$call(${JSON.stringify(target)}, [{ signal }])`);
|
|
196
|
-
const rpcEntries = Object.entries(scan.rpcActions).map(([name, key]) => [key, name]);
|
|
197
|
-
const actions = Object.entries({
|
|
198
|
-
...island.actions,
|
|
199
|
-
...Object.fromEntries(rpcEntries)
|
|
200
|
-
}).map(([key, target]) => `${JSON.stringify(key)}: (...args) => $$call(${JSON.stringify(target)}, args)`);
|
|
201
|
-
if (streams.length) wiring.push(`streams: { ${streams.join(", ")} }`);
|
|
202
|
-
if (actions.length) wiring.push(`actions: { ${actions.join(", ")} }`);
|
|
203
|
-
if (scan.clientRefs.length) wiring.push(`children: { ${scan.clientRefs.map((ref, index) => `${JSON.stringify(ref.id)}: $$child${index}`).join(", ")} }`);
|
|
204
|
-
const id = serverIslandPublicId(spec, island.name);
|
|
205
|
-
wiring.push(`frame: (props) => fetch("/__ilha/frame", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: ${JSON.stringify(id)}, path: location.pathname + location.search, props }) }).then((r) => { if (!r.ok) throw new Error("frame failed"); return r.json(); }).then((j) => { if (j.redirect) { try { const u = new URL(j.redirect, location.href); if (u.origin === location.origin) location.assign(u.pathname + u.search + u.hash); } catch {} throw new Error("frame redirected"); } __ilhaApplyHead(j.head); return j.html; })`);
|
|
206
|
-
const call = `__ilhaServerIsland(${JSON.stringify(id)}, ${JSON.stringify(island.as)}, { ${wiring.join(", ")} }, ${JSON.stringify(repaintKey)})`;
|
|
207
|
-
if (island.name === "default") lines.push(`export default ${call};`);
|
|
208
|
-
else lines.push(`export const ${island.name} = ${call};`);
|
|
209
|
-
}
|
|
210
|
-
return lines.join("\n");
|
|
211
|
-
}
|
|
212
|
-
function parseImportClause(clause) {
|
|
213
|
-
const result = { named: [] };
|
|
214
|
-
let rest = clause.trim();
|
|
215
|
-
const nsMatch = rest.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
216
|
-
if (nsMatch) {
|
|
217
|
-
result.namespace = nsMatch[1];
|
|
218
|
-
rest = rest.replace(nsMatch[0], "").replace(/,/g, "").trim();
|
|
219
|
-
}
|
|
220
|
-
const braceStart = rest.indexOf("{");
|
|
221
|
-
if (braceStart !== -1) {
|
|
222
|
-
const before = rest.slice(0, braceStart).replace(/,/g, "").trim();
|
|
223
|
-
if (before) result.defaultLocal = before;
|
|
224
|
-
const inner = rest.slice(braceStart + 1, rest.lastIndexOf("}"));
|
|
225
|
-
for (const item of inner.split(",")) {
|
|
226
|
-
const trimmed = item.trim();
|
|
227
|
-
if (!trimmed || trimmed.startsWith("type ")) continue;
|
|
228
|
-
const asMatch = trimmed.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
229
|
-
if (asMatch) result.named.push({
|
|
230
|
-
imported: asMatch[1],
|
|
231
|
-
local: asMatch[2] ?? asMatch[1]
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
} else if (rest && !result.namespace) result.defaultLocal = rest.replace(/,/g, "").trim();
|
|
235
|
-
return result;
|
|
236
|
-
}
|
|
237
|
-
/**
|
|
238
|
-
* Rewrite import sites whose specifier targets a server module containing
|
|
239
|
-
* island exports. Island bindings move to the virtual proxy module; all other
|
|
240
|
-
* bindings stay on the original specifier (oxidejs replaces them with tacho
|
|
241
|
-
* stubs). Returns null when no statement needed rewriting.
|
|
242
|
-
*/
|
|
243
|
-
function splitServerImports(code, ctx) {
|
|
244
|
-
const IMPORT_RE = /(^|\n)import\s+(?!type\b)([^'"\n]+?)\s*from\s*(["'])([^"'\n]+)\3;?/g;
|
|
245
|
-
let changed = false;
|
|
246
|
-
const out = code.replace(IMPORT_RE, (statement, lead, clause, _q, spec) => {
|
|
247
|
-
const info = ctx.islandNamesFor(spec);
|
|
248
|
-
if (!info) return statement;
|
|
249
|
-
const parsed = parseImportClause(clause);
|
|
250
|
-
const routed = [];
|
|
251
|
-
const kept = [];
|
|
252
|
-
for (const binding of parsed.named) routed.push(binding);
|
|
253
|
-
const routeDefault = parsed.defaultLocal !== void 0 && info.hasDefault;
|
|
254
|
-
if (routed.length === 0 && !routeDefault) return statement;
|
|
255
|
-
changed = true;
|
|
256
|
-
const parts = [];
|
|
257
|
-
const keptBits = [];
|
|
258
|
-
if (!routeDefault && parsed.defaultLocal) keptBits.push(parsed.defaultLocal);
|
|
259
|
-
if (kept.length) keptBits.push(`{ ${kept.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
|
|
260
|
-
if (keptBits.length) parts.push(`import ${keptBits.join(", ")} from ${JSON.stringify(spec)};`);
|
|
261
|
-
if (parsed.namespace) parts.push(`import * as ${parsed.namespace} from ${JSON.stringify(spec)};`);
|
|
262
|
-
const routedBits = [];
|
|
263
|
-
if (routeDefault) routedBits.push(parsed.defaultLocal);
|
|
264
|
-
if (routed.length) routedBits.push(`{ ${routed.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
|
|
265
|
-
parts.push(`import ${routedBits.join(", ")} from ${JSON.stringify(ctx.virtualSpecFor(spec))};`);
|
|
266
|
-
return lead + parts.join("\n");
|
|
267
|
-
});
|
|
268
|
-
return changed ? out : null;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
//#endregion
|
|
272
|
-
//#region src/codegen.ts
|
|
273
|
-
function toPosix(p) {
|
|
274
|
-
return p.replace(/\\/g, "/");
|
|
275
|
-
}
|
|
276
|
-
/** Files that should never be treated as pages even if they match the ts/tsx extension. */
|
|
277
|
-
const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
|
|
278
|
-
/** Server pages: `foo.server.tsx` routes `/foo`, rendered through the frame protocol. */
|
|
279
|
-
const SERVER_PAGE_RE = /\.server\.(ts|tsx)$/;
|
|
280
|
-
function fileToSegment(name) {
|
|
281
|
-
if (name.startsWith("[...") && name.endsWith("]")) return `**:${name.slice(4, -1)}`;
|
|
282
|
-
if (name.startsWith("[") && name.endsWith("]")) return `:${name.slice(1, -1)}`;
|
|
283
|
-
return name;
|
|
284
|
-
}
|
|
285
|
-
/** Route-group directories like "(auth)" are transparent to the URL. */
|
|
286
|
-
function dirToSegment(name) {
|
|
287
|
-
if (name.startsWith("(") && name.endsWith(")")) return "";
|
|
288
|
-
return fileToSegment(name);
|
|
289
|
-
}
|
|
290
|
-
function fileToPattern(pagesDir, file) {
|
|
291
|
-
const rel = toPosix(relative(pagesDir, file));
|
|
292
|
-
let noExt = rel.slice(0, -extname(rel).length);
|
|
293
|
-
if (SERVER_PAGE_RE.test(rel)) noExt = noExt.replace(/\.server$/, "");
|
|
294
|
-
const parts = noExt.split("/");
|
|
295
|
-
const segments = [...parts.slice(0, -1).map(dirToSegment), fileToSegment(parts.at(-1))];
|
|
296
|
-
if (segments.at(-1) === "index") segments.pop();
|
|
297
|
-
return "/" + segments.filter(Boolean).join("/") || "/";
|
|
298
|
-
}
|
|
299
|
-
function patternToName(pattern) {
|
|
300
|
-
if (pattern === "/") return "index";
|
|
301
|
-
return pattern.replace(/^\//, "").replace(/\*\*:[^/]*/g, (m) => m.length > 3 ? m.slice(3) : "wildcard").replace(/:/g, "").replace(/\*\*/g, "wildcard").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "") || "page";
|
|
302
|
-
}
|
|
303
|
-
function specificityScore(pattern) {
|
|
304
|
-
if (pattern === "/") return 3;
|
|
305
|
-
if (pattern.includes("**")) return 0;
|
|
306
|
-
if (pattern.includes(":")) return 1;
|
|
307
|
-
return 2;
|
|
308
|
-
}
|
|
309
|
-
/** Deterministic sort: by specificity desc, then by segment count desc, then alphabetical. */
|
|
310
|
-
function sortEntries(entries) {
|
|
311
|
-
return [...entries].sort((a, b) => {
|
|
312
|
-
const specDiff = specificityScore(b.pattern) - specificityScore(a.pattern);
|
|
313
|
-
if (specDiff !== 0) return specDiff;
|
|
314
|
-
const segDiff = b.pattern.split("/").length - a.pattern.split("/").length;
|
|
315
|
-
if (segDiff !== 0) return segDiff;
|
|
316
|
-
return a.pattern.localeCompare(b.pattern);
|
|
317
|
-
});
|
|
318
|
-
}
|
|
319
|
-
function chainForFile(pagesDir, file, all, sentinel) {
|
|
320
|
-
const relDir = toPosix(relative(pagesDir, dirname(file)));
|
|
321
|
-
const parts = relDir === "" ? [] : relDir.split("/");
|
|
322
|
-
const dirs = [pagesDir, ...parts.map((_, i) => join(pagesDir, ...parts.slice(0, i + 1)))];
|
|
323
|
-
const candidatesFor = (dir) => {
|
|
324
|
-
const tsx = `${join(dir, sentinel)}.tsx`;
|
|
325
|
-
if (all.has(tsx)) return [tsx];
|
|
326
|
-
const ts = `${join(dir, sentinel)}.ts`;
|
|
327
|
-
if (all.has(ts)) return [ts];
|
|
328
|
-
return [];
|
|
329
|
-
};
|
|
330
|
-
return dirs.flatMap(candidatesFor);
|
|
331
|
-
}
|
|
332
|
-
const MAX_SCAN_DEPTH = 20;
|
|
333
|
-
async function collectFiles(dir, depth = 0) {
|
|
334
|
-
if (depth > MAX_SCAN_DEPTH) {
|
|
335
|
-
console.warn(`[ilha:pages] Max scan depth (${MAX_SCAN_DEPTH}) reached at ${dir} — skipping`);
|
|
336
|
-
return [];
|
|
337
|
-
}
|
|
338
|
-
const results = [];
|
|
339
|
-
try {
|
|
340
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
341
|
-
const full = join(dir, entry.name);
|
|
342
|
-
if (entry.isDirectory()) results.push(...await collectFiles(full, depth + 1));
|
|
343
|
-
else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !EXCLUDED_RE.test(entry.name)) results.push(full);
|
|
344
|
-
}
|
|
345
|
-
} catch (e) {
|
|
346
|
-
if (e.code === "ENOENT") return [];
|
|
347
|
-
throw e;
|
|
348
|
-
}
|
|
349
|
-
return results;
|
|
350
|
-
}
|
|
351
|
-
async function scanPages(pagesDir) {
|
|
352
|
-
const all = await collectFiles(pagesDir);
|
|
353
|
-
const allSet = new Set(all);
|
|
354
|
-
return all.filter((f) => !basename(f).startsWith("+")).map((file) => {
|
|
355
|
-
const pattern = fileToPattern(pagesDir, file);
|
|
356
|
-
return {
|
|
357
|
-
file,
|
|
358
|
-
pattern,
|
|
359
|
-
name: patternToName(pattern),
|
|
360
|
-
layouts: chainForFile(pagesDir, file, allSet, "+layout"),
|
|
361
|
-
errors: chainForFile(pagesDir, file, allSet, "+error"),
|
|
362
|
-
server: SERVER_PAGE_RE.test(basename(file))
|
|
363
|
-
};
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
function validateEntries(entries, pagesDir, strict) {
|
|
367
|
-
if (entries.length === 0) {
|
|
368
|
-
console.warn(`[ilha:pages] No pages found in ${pagesDir}`);
|
|
369
|
-
return;
|
|
370
|
-
}
|
|
371
|
-
const seenPatterns = /* @__PURE__ */ new Map();
|
|
372
|
-
const seenNames = /* @__PURE__ */ new Map();
|
|
373
|
-
const problems = [];
|
|
374
|
-
for (const entry of entries) {
|
|
375
|
-
if (entry.server) try {
|
|
376
|
-
const scan = loadServerModuleScan(entry.file);
|
|
377
|
-
if (!scan.islands.some((island) => island.name === "default") && !scan.exports.includes("default")) throw new Error(`[ilha:pages] Server page ${entry.file} has no default export.`);
|
|
378
|
-
} catch (err) {
|
|
379
|
-
if (err instanceof Error && err.message.includes("[ilha:pages]")) throw err;
|
|
380
|
-
throw new Error(`[ilha:pages] Server page ${entry.file} could not be scanned for island exports.`);
|
|
381
|
-
}
|
|
382
|
-
const existingPattern = seenPatterns.get(entry.pattern);
|
|
383
|
-
if (existingPattern) problems.push(`Duplicate route pattern "${entry.pattern}"\n first: ${existingPattern}\n second: ${entry.file}\n The first match wins — the second page will never be reached.`);
|
|
384
|
-
else seenPatterns.set(entry.pattern, entry.file);
|
|
385
|
-
const existingName = seenNames.get(entry.name);
|
|
386
|
-
if (existingName) problems.push(`Registry name collision: "${entry.name}" is used by both\n ${existingName}\n ${entry.file}\n Hydration may not work correctly for one of these routes.`);
|
|
387
|
-
else seenNames.set(entry.name, entry.file);
|
|
388
|
-
}
|
|
389
|
-
if (problems.length === 0) return;
|
|
390
|
-
if (strict) throw new Error(`[ilha:pages] Route validation failed:\n\n${problems.join("\n\n")}`);
|
|
391
|
-
for (const p of problems) console.warn(`[ilha:pages] ${p}`);
|
|
392
|
-
}
|
|
393
|
-
function resolveGeneratedPaths(outDir) {
|
|
394
|
-
return {
|
|
395
|
-
serverFile: join(outDir, "pages.server.ts"),
|
|
396
|
-
clientFile: join(outDir, "pages.client.ts")
|
|
397
|
-
};
|
|
398
|
-
}
|
|
399
|
-
async function generate(pagesDir, outDir, options = {}) {
|
|
400
|
-
const mode = options.mode ?? "spa";
|
|
401
|
-
const interceptLinks = options.interceptLinks;
|
|
402
|
-
const isStatic = mode === "static";
|
|
403
|
-
const entries = sortEntries(await scanPages(pagesDir));
|
|
404
|
-
validateEntries(entries, pagesDir, options.strict === true);
|
|
405
|
-
await mkdir(outDir, { recursive: true });
|
|
406
|
-
const { serverFile, clientFile } = resolveGeneratedPaths(outDir);
|
|
407
|
-
const serverChanged = await writeIfChanged(serverFile, buildServerFile(entries, serverFile));
|
|
408
|
-
const clientChanged = await writeIfChanged(clientFile, buildClientFile(entries, clientFile, {
|
|
409
|
-
isStatic,
|
|
410
|
-
interceptLinks
|
|
411
|
-
}));
|
|
412
|
-
if (!isStatic) {}
|
|
413
|
-
if (serverChanged || clientChanged) await generateTypes(outDir);
|
|
414
|
-
}
|
|
415
|
-
function buildServerFile(entries, serverFile) {
|
|
416
|
-
const rel = (abs) => {
|
|
417
|
-
const r = toPosix(relative(dirname(serverFile), abs));
|
|
418
|
-
return r.startsWith(".") ? r : `./${r}`;
|
|
419
|
-
};
|
|
420
|
-
const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
|
|
421
|
-
const wrappedIslandLines = [];
|
|
422
|
-
const registryLines = [];
|
|
423
|
-
const routeLines = [];
|
|
424
|
-
for (const [i, entry] of entries.entries()) {
|
|
425
|
-
imports.push(`import { default as _page${i} } from ${JSON.stringify(rel(entry.file))};`);
|
|
426
|
-
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(rel(l))};`);
|
|
427
|
-
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(rel(e))};`);
|
|
428
|
-
let expr = `_page${i}`;
|
|
429
|
-
for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
|
|
430
|
-
for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
|
|
431
|
-
const wrappedId = `_wrapped${i}`;
|
|
432
|
-
wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
|
|
433
|
-
registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
|
|
434
|
-
routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
|
|
435
|
-
if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
|
|
436
|
-
}
|
|
437
|
-
return [
|
|
438
|
-
`// @generated by @ilha/router — do not edit`,
|
|
439
|
-
`// Server module. Use for SSR and SSG/prerender.`,
|
|
440
|
-
`// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
|
|
441
|
-
``,
|
|
442
|
-
...imports,
|
|
443
|
-
``,
|
|
444
|
-
...wrappedIslandLines,
|
|
445
|
-
``,
|
|
446
|
-
`export const registry: Record<string, Island<any>> = {`,
|
|
447
|
-
...registryLines,
|
|
448
|
-
`};`,
|
|
449
|
-
``,
|
|
450
|
-
`export const pageRouter = router()`,
|
|
451
|
-
...routeLines,
|
|
452
|
-
` ;`
|
|
453
|
-
].join("\n");
|
|
454
|
-
}
|
|
455
|
-
function buildClientFile(entries, clientFile, opts) {
|
|
456
|
-
const { isStatic, interceptLinks } = opts;
|
|
457
|
-
const rel = (abs) => {
|
|
458
|
-
const r = toPosix(relative(dirname(clientFile), abs));
|
|
459
|
-
return r.startsWith(".") ? r : `./${r}`;
|
|
460
|
-
};
|
|
461
|
-
const clientImport = (abs) => `${rel(abs)}?client`;
|
|
462
|
-
const imports = isStatic ? [`import { router as _router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`] : [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
|
|
463
|
-
const wrappedIslandLines = [];
|
|
464
|
-
const registryLines = [];
|
|
465
|
-
const routeLines = [];
|
|
466
|
-
for (const [i, entry] of entries.entries()) {
|
|
467
|
-
if (entry.server) {
|
|
468
|
-
imports.push(`import { default as _page${i} } from ${JSON.stringify(serverIslandVirtualSpec(entry.file))};`);
|
|
469
|
-
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
|
|
470
|
-
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
|
|
471
|
-
let serverExpr = `_page${i}`;
|
|
472
|
-
for (let j = entry.errors.length - 1; j >= 0; j--) serverExpr = `wrapError(_error${i}_${j}, ${serverExpr})`;
|
|
473
|
-
for (let j = entry.layouts.length - 1; j >= 0; j--) serverExpr = `wrapLayout(_layout${i}_${j}, ${serverExpr})`;
|
|
474
|
-
const wrappedServerId = `_wrapped${i}`;
|
|
475
|
-
wrappedIslandLines.push(`const ${wrappedServerId} = ${serverExpr};`);
|
|
476
|
-
registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedServerId}` + (i < entries.length - 1 ? "," : ""));
|
|
477
|
-
if (!isStatic) {
|
|
478
|
-
routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedServerId})`);
|
|
479
|
-
if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
|
|
480
|
-
}
|
|
481
|
-
continue;
|
|
482
|
-
}
|
|
483
|
-
imports.push(`import { default as _page${i} } from ${JSON.stringify(clientImport(entry.file))};`);
|
|
484
|
-
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
|
|
485
|
-
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
|
|
486
|
-
let expr = `_page${i}`;
|
|
487
|
-
for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
|
|
488
|
-
for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
|
|
489
|
-
const wrappedId = `_wrapped${i}`;
|
|
490
|
-
wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
|
|
491
|
-
registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
|
|
492
|
-
if (!isStatic) {
|
|
493
|
-
routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
|
|
494
|
-
if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
|
|
495
|
-
}
|
|
496
|
-
}
|
|
497
|
-
const routerExpr = isStatic ? `_router({ mode: "static" })` : `router(${interceptLinks === false ? `{ interceptLinks: false }` : ""})`;
|
|
498
|
-
const lines = [
|
|
499
|
-
`// @generated by @ilha/router — do not edit`,
|
|
500
|
-
`// Client module. Use for browser hydration.`,
|
|
501
|
-
`// Import via: import { pageRouter, registry } from "ilha:pages/client";`,
|
|
502
|
-
``,
|
|
503
|
-
...imports,
|
|
504
|
-
``,
|
|
505
|
-
...wrappedIslandLines,
|
|
506
|
-
``,
|
|
507
|
-
`export const registry: Record<string, Island<any>> = {`,
|
|
508
|
-
...registryLines,
|
|
509
|
-
`};`,
|
|
510
|
-
``
|
|
511
|
-
];
|
|
512
|
-
if (isStatic) lines.push(`export const pageRouter = ${routerExpr};`);
|
|
513
|
-
else lines.push(`export const pageRouter = ${routerExpr}`, ...routeLines, ` ;`);
|
|
514
|
-
return lines.join("\n");
|
|
515
|
-
}
|
|
516
|
-
async function writeIfChanged(file, content) {
|
|
517
|
-
try {
|
|
518
|
-
if (await readFile(file, "utf8") === content) return false;
|
|
519
|
-
} catch {}
|
|
520
|
-
await writeFile(file, content, "utf8");
|
|
521
|
-
return true;
|
|
522
|
-
}
|
|
523
|
-
async function generateTypes(outDir) {
|
|
524
|
-
await writeIfChanged(join(outDir, "pages.d.ts"), [
|
|
525
|
-
`// @generated by @ilha/router — do not edit`,
|
|
526
|
-
``,
|
|
527
|
-
`declare module "ilha:pages/server" {`,
|
|
528
|
-
` import type { RouterBuilder } from "@ilha/router";`,
|
|
529
|
-
` import type { Island } from "ilha";`,
|
|
530
|
-
` export const pageRouter: RouterBuilder;`,
|
|
531
|
-
` export const registry: Record<string, Island<any>>;`,
|
|
532
|
-
`}`,
|
|
533
|
-
``,
|
|
534
|
-
`declare module "ilha:pages/client" {`,
|
|
535
|
-
` import type { RouterBuilder } from "@ilha/router";`,
|
|
536
|
-
` import type { Island } from "ilha";`,
|
|
537
|
-
` export const pageRouter: RouterBuilder;`,
|
|
538
|
-
` export const registry: Record<string, Island<any>>;`,
|
|
539
|
-
`}`,
|
|
540
|
-
``,
|
|
541
|
-
`declare module "ilha:loaders" {`,
|
|
542
|
-
` // Side-effect-only module. oxidejs imports it alongside @ilha/router/ssr.`,
|
|
543
|
-
`}`,
|
|
544
|
-
``
|
|
545
|
-
].join("\n"));
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
//#endregion
|
|
549
|
-
//#region src/plugin.ts
|
|
550
|
-
function devServerOrigin(server) {
|
|
551
|
-
const configured = server.config.server.origin;
|
|
552
|
-
if (configured) return new URL(typeof configured === "string" ? configured : String(configured)).origin;
|
|
553
|
-
const local = server.resolvedUrls?.local?.[0];
|
|
554
|
-
if (local) return new URL(local).origin;
|
|
555
|
-
const { host, port, https } = server.config.server;
|
|
556
|
-
return `${https ? "https" : "http"}://${host === true || host === void 0 || host === "" ? "localhost" : host === false ? "localhost" : String(host)}:${typeof port === "number" ? port : port ? Number(port) : 5173}`;
|
|
557
|
-
}
|
|
558
|
-
const VIRTUAL_PAGES_SERVER = "ilha:pages/server";
|
|
559
|
-
const VIRTUAL_PAGES_CLIENT = "ilha:pages/client";
|
|
560
|
-
const VIRTUAL_LOADERS = "ilha:loaders";
|
|
561
|
-
const RESOLVED_PAGES_SERVER = "\0ilha:pages/server";
|
|
562
|
-
const RESOLVED_PAGES_CLIENT = "\0ilha:pages/client";
|
|
563
|
-
const RESOLVED_LOADERS = "\0ilha:loaders";
|
|
564
|
-
const RESOLVED_VIRTUAL_IDS = [
|
|
565
|
-
RESOLVED_PAGES_SERVER,
|
|
566
|
-
RESOLVED_PAGES_CLIENT,
|
|
567
|
-
RESOLVED_LOADERS
|
|
568
|
-
];
|
|
569
|
-
/** Query suffix used on page/layout imports in the client file. */
|
|
570
|
-
const CLIENT_QUERY = "?client";
|
|
571
|
-
function decodeServerIslandId(id) {
|
|
572
|
-
if (!id.startsWith("\0ilha:server-island:")) return null;
|
|
573
|
-
try {
|
|
574
|
-
return Buffer.from(id.slice(SERVER_ISLAND_PREFIX.length), "base64url").toString();
|
|
575
|
-
} catch {
|
|
576
|
-
return null;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
const SERVER_FILE_RE = /\.server\.(ts|tsx|js|jsx)$/;
|
|
580
|
-
/** Cheap prefilter — most modules never mention a server import. Extension
|
|
581
|
-
* optional: aliases like `$lib/tasks.server` resolve to `.server.tsx` later. */
|
|
582
|
-
const SERVER_SPEC_HINT_RE = /["'][^"']*\.server(\.[cm]?[jt]sx?)?["']/;
|
|
583
|
-
/** mtime-keyed scan cache so repeated transforms don't re-read/re-parse. */
|
|
584
|
-
const scanCache = /* @__PURE__ */ new Map();
|
|
585
|
-
function scanFor(path) {
|
|
586
|
-
try {
|
|
587
|
-
const mtimeMs = statSync(path).mtimeMs;
|
|
588
|
-
const cached = scanCache.get(path);
|
|
589
|
-
if (cached && cached.mtimeMs === mtimeMs) return cached.scan;
|
|
590
|
-
const scan = loadServerModuleScan(path);
|
|
591
|
-
if (scan.islands.length === 0) return null;
|
|
592
|
-
scanCache.set(path, {
|
|
593
|
-
mtimeMs,
|
|
594
|
-
scan
|
|
595
|
-
});
|
|
596
|
-
return scan;
|
|
597
|
-
} catch {
|
|
598
|
-
return null;
|
|
599
|
-
}
|
|
600
|
-
}
|
|
601
|
-
/** Read & parse a package.json, returning null on any error. */
|
|
602
|
-
function readJson(path) {
|
|
603
|
-
try {
|
|
604
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
605
|
-
} catch {
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
/** Resolve a dependency's package.json by walking up node_modules from `root`. */
|
|
610
|
-
function readDepPackageJson(root, name) {
|
|
611
|
-
let dir = root;
|
|
612
|
-
for (;;) {
|
|
613
|
-
const candidate = join(dir, "node_modules", name, "package.json");
|
|
614
|
-
if (existsSync(candidate)) return readJson(candidate);
|
|
615
|
-
const parent = dirname(dir);
|
|
616
|
-
if (parent === dir) return null;
|
|
617
|
-
dir = parent;
|
|
618
|
-
}
|
|
619
|
-
}
|
|
620
|
-
/**
|
|
621
|
-
* Find app dependencies that bridge ilha primitives — i.e. declare `ilha` as a
|
|
622
|
-
* peer or dependency (e.g. a UI library like `areia`). They render islands with
|
|
623
|
-
* `bind:*`/slot directives, so they MUST share the app's single ilha instance.
|
|
624
|
-
* Returned here so the plugin can give them the same `dedupe` + `ssr.noExternal`
|
|
625
|
-
* treatment as the framework singletons; otherwise SSR externalizes them with
|
|
626
|
-
* their own ilha copy (a second renderCtxStack) and hydration silently breaks.
|
|
627
|
-
* Keeps app vite configs minimal — no manual `noExternal: ["areia"]` needed.
|
|
628
|
-
*/
|
|
629
|
-
function detectIlhaConsumers(root) {
|
|
630
|
-
const appPkg = readJson(join(root, "package.json"));
|
|
631
|
-
if (!appPkg) return [];
|
|
632
|
-
const deps = {
|
|
633
|
-
...appPkg.dependencies,
|
|
634
|
-
...appPkg.devDependencies
|
|
635
|
-
};
|
|
636
|
-
const found = [];
|
|
637
|
-
for (const name of Object.keys(deps)) {
|
|
638
|
-
if (name === "ilha") continue;
|
|
639
|
-
const pkg = readDepPackageJson(root, name);
|
|
640
|
-
if (!pkg) continue;
|
|
641
|
-
const peers = pkg.peerDependencies ?? {};
|
|
642
|
-
const directDeps = pkg.dependencies ?? {};
|
|
643
|
-
if ("ilha" in peers || "ilha" in directDeps) found.push(name);
|
|
644
|
-
}
|
|
645
|
-
return found;
|
|
646
|
-
}
|
|
647
|
-
function resolvePluginPaths(root, options) {
|
|
648
|
-
const pagesDir = resolve(root, options.dir ?? "src/pages");
|
|
649
|
-
const outDir = resolve(root, options.outDir ?? ".ilha");
|
|
650
|
-
const { serverFile, clientFile } = resolveGeneratedPaths(outDir);
|
|
651
|
-
return {
|
|
652
|
-
pagesDir,
|
|
653
|
-
outDir,
|
|
654
|
-
serverFile,
|
|
655
|
-
clientFile
|
|
656
|
-
};
|
|
657
|
-
}
|
|
658
|
-
function createPagesPluginState(options) {
|
|
659
|
-
let pagesDir;
|
|
660
|
-
let outDir;
|
|
661
|
-
let serverFile;
|
|
662
|
-
let clientFile;
|
|
663
|
-
const setPaths = (root) => {
|
|
664
|
-
({pagesDir, outDir, serverFile, clientFile} = resolvePluginPaths(root, options));
|
|
665
|
-
};
|
|
666
|
-
const regen = async () => {
|
|
667
|
-
try {
|
|
668
|
-
await generate(pagesDir, outDir, {
|
|
669
|
-
mode: options.mode,
|
|
670
|
-
interceptLinks: options.interceptLinks,
|
|
671
|
-
strict: options.strict
|
|
672
|
-
});
|
|
673
|
-
} catch (e) {
|
|
674
|
-
console.error("[ilha:pages] codegen failed:", e);
|
|
675
|
-
if (options.strict) throw e;
|
|
676
|
-
}
|
|
677
|
-
};
|
|
678
|
-
const isUnderPagesDir = (file) => file === pagesDir || file.startsWith(pagesDir + sep);
|
|
679
|
-
const shouldRegenOnChange = (file) => {
|
|
680
|
-
if (!isUnderPagesDir(file)) return false;
|
|
681
|
-
const base = basename(file);
|
|
682
|
-
return base.startsWith("+") || /\.(ts|tsx)$/.test(base);
|
|
683
|
-
};
|
|
684
|
-
return {
|
|
685
|
-
get pagesDir() {
|
|
686
|
-
return pagesDir;
|
|
687
|
-
},
|
|
688
|
-
get outDir() {
|
|
689
|
-
return outDir;
|
|
690
|
-
},
|
|
691
|
-
get serverFile() {
|
|
692
|
-
return serverFile;
|
|
693
|
-
},
|
|
694
|
-
get clientFile() {
|
|
695
|
-
return clientFile;
|
|
696
|
-
},
|
|
697
|
-
setPaths,
|
|
698
|
-
regen,
|
|
699
|
-
shouldRegenOnChange,
|
|
700
|
-
isUnderPagesDir
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
async function regenFromPagesChange(state, file, shouldRegen) {
|
|
704
|
-
if (!shouldRegen(file)) return;
|
|
705
|
-
await state.regen();
|
|
706
|
-
}
|
|
707
|
-
function resolvePagesId(state, id, importer) {
|
|
708
|
-
if (id === "ilha:pages/server") return RESOLVED_PAGES_SERVER;
|
|
709
|
-
if (id === "ilha:pages/client") return RESOLVED_PAGES_CLIENT;
|
|
710
|
-
if (id === "ilha:loaders") return RESOLVED_LOADERS;
|
|
711
|
-
for (const query of [CLIENT_QUERY]) {
|
|
712
|
-
if (!id.endsWith(query)) continue;
|
|
713
|
-
const bare = id.slice(0, -query.length);
|
|
714
|
-
const resolved = importer ? resolve(importer.replace(/\?.*$/, ""), "..", bare) : resolve(bare);
|
|
715
|
-
if (!state.pagesDir || !state.isUnderPagesDir(resolved)) return;
|
|
716
|
-
return resolved + query;
|
|
717
|
-
}
|
|
718
|
-
}
|
|
719
|
-
function loadPagesModule(state, id) {
|
|
720
|
-
if (id === "\0ilha:pages/server") {
|
|
721
|
-
const spec = state.serverFile.replace(/\.tsx?$/, "");
|
|
722
|
-
return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
|
|
723
|
-
}
|
|
724
|
-
if (id === "\0ilha:pages/client") {
|
|
725
|
-
const spec = state.clientFile.replace(/\.tsx?$/, "");
|
|
726
|
-
return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
|
|
727
|
-
}
|
|
728
|
-
if (id === "\0ilha:loaders") return `// @generated by @ilha/router — do not edit\nexport {};\n`;
|
|
729
|
-
if (id.endsWith("?client")) {
|
|
730
|
-
const bare = id.slice(0, -7);
|
|
731
|
-
return `export { default } from ${JSON.stringify(bare)};`;
|
|
732
|
-
}
|
|
733
|
-
}
|
|
734
|
-
function createStructuralInvalidate(state, invalidate) {
|
|
735
|
-
return async (file) => {
|
|
736
|
-
if (!state.isUnderPagesDir(file)) return;
|
|
737
|
-
await state.regen();
|
|
738
|
-
await invalidate();
|
|
739
|
-
};
|
|
740
|
-
}
|
|
741
|
-
function setupRspackPagesWatcher(state, structuralInvalidate) {
|
|
742
|
-
let watcher = null;
|
|
743
|
-
let poll = null;
|
|
744
|
-
let closed = false;
|
|
745
|
-
const attach = () => {
|
|
746
|
-
watcher = watch(state.pagesDir, { recursive: true }, (_event, filename) => {
|
|
747
|
-
if (!filename) return;
|
|
748
|
-
structuralInvalidate(join(state.pagesDir, filename));
|
|
749
|
-
});
|
|
750
|
-
};
|
|
751
|
-
if (existsSync(state.pagesDir)) attach();
|
|
752
|
-
else {
|
|
753
|
-
poll = setInterval(() => {
|
|
754
|
-
if (closed || !existsSync(state.pagesDir)) return;
|
|
755
|
-
clearInterval(poll);
|
|
756
|
-
poll = null;
|
|
757
|
-
attach();
|
|
758
|
-
structuralInvalidate(join(state.pagesDir, "."));
|
|
759
|
-
}, 1e3);
|
|
760
|
-
poll.unref?.();
|
|
761
|
-
}
|
|
762
|
-
return () => {
|
|
763
|
-
closed = true;
|
|
764
|
-
if (poll) clearInterval(poll);
|
|
765
|
-
watcher?.close();
|
|
766
|
-
};
|
|
767
|
-
}
|
|
768
|
-
const pagesFactory = (options = {}) => {
|
|
769
|
-
const state = createPagesPluginState(options);
|
|
770
|
-
const serverIslands = /* @__PURE__ */ new Map();
|
|
771
|
-
return {
|
|
772
|
-
name: "ilha:pages",
|
|
773
|
-
async buildStart() {
|
|
774
|
-
if (!state.pagesDir) state.setPaths(process.cwd());
|
|
775
|
-
this.addWatchFile?.(state.pagesDir);
|
|
776
|
-
await state.regen();
|
|
777
|
-
},
|
|
778
|
-
async watchChange(file) {
|
|
779
|
-
await regenFromPagesChange(state, file, (f) => state.shouldRegenOnChange(f));
|
|
780
|
-
},
|
|
781
|
-
resolveId(id, importer) {
|
|
782
|
-
if (id.startsWith("\0ilha:server-island:")) return id;
|
|
783
|
-
return resolvePagesId(state, id, importer);
|
|
784
|
-
},
|
|
785
|
-
load(id) {
|
|
786
|
-
const islandFile = decodeServerIslandId(id);
|
|
787
|
-
if (islandFile !== null) {
|
|
788
|
-
const scan = loadServerModuleScan(islandFile);
|
|
789
|
-
for (const island of scan.islands) serverIslands.set(serverIslandPublicId(islandFile, island.name), {
|
|
790
|
-
file: islandFile,
|
|
791
|
-
name: island.name
|
|
792
|
-
});
|
|
793
|
-
return generateServerIslandModule(islandFile, scan);
|
|
794
|
-
}
|
|
795
|
-
return loadPagesModule(state, id);
|
|
796
|
-
},
|
|
797
|
-
vite: {
|
|
798
|
-
config(userConfig) {
|
|
799
|
-
const singletonPeers = [
|
|
800
|
-
"ilha",
|
|
801
|
-
"@ilha/router",
|
|
802
|
-
...detectIlhaConsumers(userConfig.root ? resolve(userConfig.root) : process.cwd())
|
|
803
|
-
];
|
|
804
|
-
const existingNoExternal = userConfig.ssr?.noExternal;
|
|
805
|
-
const noExternal = existingNoExternal === true ? true : [.../* @__PURE__ */ new Set([...Array.isArray(existingNoExternal) ? existingNoExternal : existingNoExternal == null ? [] : [existingNoExternal], ...singletonPeers])];
|
|
806
|
-
return {
|
|
807
|
-
resolve: { dedupe: [.../* @__PURE__ */ new Set([...userConfig.resolve?.dedupe ?? [], ...singletonPeers])] },
|
|
808
|
-
ssr: { noExternal },
|
|
809
|
-
optimizeDeps: {
|
|
810
|
-
...userConfig.optimizeDeps,
|
|
811
|
-
include: [.../* @__PURE__ */ new Set([
|
|
812
|
-
...userConfig.optimizeDeps?.include ?? [],
|
|
813
|
-
"ilha",
|
|
814
|
-
"ilha/jsx-runtime",
|
|
815
|
-
"ilha/jsx-dev-runtime"
|
|
816
|
-
])]
|
|
817
|
-
}
|
|
818
|
-
};
|
|
819
|
-
},
|
|
820
|
-
configResolved(config) {
|
|
821
|
-
state.setPaths(config.root);
|
|
822
|
-
},
|
|
823
|
-
async transform(code, id, opts) {
|
|
824
|
-
const file = id.replace(/\?.*$/, "");
|
|
825
|
-
const serverFile = SERVER_FILE_RE.test(file);
|
|
826
|
-
if (opts?.ssr) {
|
|
827
|
-
if (!serverFile) return null;
|
|
828
|
-
const scan = scanFor(file);
|
|
829
|
-
const lines = [];
|
|
830
|
-
for (const ref of scan?.clientRefs ?? []) lines.push(`if (${ref.local}?.[Symbol.for("ilha.island")]) ${ref.local}[Symbol.for("ilha.clientRef")] = ${JSON.stringify(ref.id)};`);
|
|
831
|
-
if (scan && scan.islands.length > 0 && !code.startsWith("// oxidejs:client-stub")) {
|
|
832
|
-
if (Object.keys(scan.rpcActions).length > 0) lines.unshift(`import { __ilhaServerAction } from "@ilha/router/ssr";`);
|
|
833
|
-
lines.unshift(`import * as __ilhaSelf from ${JSON.stringify(file)};`);
|
|
834
|
-
lines.unshift(`import { registerServerIsland } from "@ilha/router/ssr";`);
|
|
835
|
-
for (const island of scan.islands) lines.push(`registerServerIsland(${JSON.stringify(serverIslandPublicId(file, island.name))}, () => __ilhaSelf[${JSON.stringify(island.name)}]);`);
|
|
836
|
-
}
|
|
837
|
-
const base = rewriteServerActions(code, scan?.rpcActions ?? {});
|
|
838
|
-
if (base === code && lines.length === 0) return null;
|
|
839
|
-
if (lines.length === 0) return base;
|
|
840
|
-
return `${base}\n${lines.join("\n")}`;
|
|
841
|
-
}
|
|
842
|
-
if (id.startsWith("\0") || id.includes("node_modules")) return null;
|
|
843
|
-
if (serverFile) return null;
|
|
844
|
-
if (!SERVER_SPEC_HINT_RE.test(code)) return null;
|
|
845
|
-
const SPEC_RE = /(?:^|\n)\s*import\s+(?:type\s+)?[^'"\n]+?\s*from\s*["']([^"']+)["']/g;
|
|
846
|
-
const specs = /* @__PURE__ */ new Set();
|
|
847
|
-
for (const match of code.matchAll(SPEC_RE)) specs.add(match[1]);
|
|
848
|
-
if (specs.size === 0) return null;
|
|
849
|
-
const scanned = /* @__PURE__ */ new Map();
|
|
850
|
-
for (const spec of specs) {
|
|
851
|
-
let file;
|
|
852
|
-
try {
|
|
853
|
-
const resolved = await this.resolve?.(spec, id);
|
|
854
|
-
file = resolved?.path ?? resolved?.id;
|
|
855
|
-
} catch {
|
|
856
|
-
file = void 0;
|
|
857
|
-
}
|
|
858
|
-
if (!file && spec.startsWith("/")) file = spec;
|
|
859
|
-
if (!file || !SERVER_FILE_RE.test(file.replace(/\?.*$/, ""))) continue;
|
|
860
|
-
const scan = scanFor(file);
|
|
861
|
-
if (!scan) continue;
|
|
862
|
-
scanned.set(spec, {
|
|
863
|
-
file,
|
|
864
|
-
islands: new Set(scan.islands.filter((i) => i.name !== "default").map((i) => i.name)),
|
|
865
|
-
hasDefault: scan.islands.some((i) => i.name === "default")
|
|
866
|
-
});
|
|
867
|
-
}
|
|
868
|
-
if (scanned.size === 0) return null;
|
|
869
|
-
return splitServerImports(code, {
|
|
870
|
-
islandNamesFor: (spec) => {
|
|
871
|
-
const entry = scanned.get(spec);
|
|
872
|
-
return entry ? {
|
|
873
|
-
islands: entry.islands,
|
|
874
|
-
hasDefault: entry.hasDefault
|
|
875
|
-
} : null;
|
|
876
|
-
},
|
|
877
|
-
virtualSpecFor: (spec) => serverIslandVirtualSpec(scanned.get(spec).file)
|
|
878
|
-
});
|
|
879
|
-
},
|
|
880
|
-
configureServer(server) {
|
|
881
|
-
server.watcher.add(state.pagesDir);
|
|
882
|
-
if (options.frameGuard) setFrameGuard(options.frameGuard);
|
|
883
|
-
setFrameAuth({
|
|
884
|
-
trustedOrigins: options.trustedOrigins,
|
|
885
|
-
csrf: options.csrf,
|
|
886
|
-
defaultAction: "open"
|
|
887
|
-
});
|
|
888
|
-
server.middlewares.use(async (req, res, next) => {
|
|
889
|
-
if ((req.url ?? "").split("?")[0] !== "/__ilha/frame") return next();
|
|
890
|
-
if (req.method !== "POST") {
|
|
891
|
-
res.statusCode = 405;
|
|
892
|
-
res.end();
|
|
893
|
-
return;
|
|
894
|
-
}
|
|
895
|
-
if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
896
|
-
res.statusCode = 415;
|
|
897
|
-
res.end();
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
const serverOrigin = devServerOrigin(server);
|
|
901
|
-
const frameUrl = `${serverOrigin}${req.url ?? "/"}`;
|
|
902
|
-
const authorized = await authorizeFrameRequest(new Request(frameUrl, {
|
|
903
|
-
method: req.method,
|
|
904
|
-
headers: new Headers(req.headers)
|
|
905
|
-
}), {
|
|
906
|
-
defaultAction: "open",
|
|
907
|
-
onGuardError: () => {}
|
|
908
|
-
});
|
|
909
|
-
if (!authorized.ok) {
|
|
910
|
-
if (authorized.status === 403 && !getFrameGuard()) console.warn(`[ilha-router] dev frame request rejected: Origin ${String(req.headers.origin)} is not trusted (host: ${String(req.headers.host ?? "localhost")}). Configure trustedOrigins via IlhaPagesOptions if this origin is expected.`);
|
|
911
|
-
res.statusCode = authorized.status;
|
|
912
|
-
res.end();
|
|
913
|
-
return;
|
|
914
|
-
}
|
|
915
|
-
const chunks = [];
|
|
916
|
-
let size = 0;
|
|
917
|
-
for await (const chunk of req) {
|
|
918
|
-
size += chunk.length;
|
|
919
|
-
if (size > 16384) {
|
|
920
|
-
res.statusCode = 413;
|
|
921
|
-
res.end();
|
|
922
|
-
return;
|
|
923
|
-
}
|
|
924
|
-
chunks.push(chunk);
|
|
925
|
-
}
|
|
926
|
-
try {
|
|
927
|
-
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
928
|
-
const incomingProps = parseFrameProps(body.props);
|
|
929
|
-
const target = serverIslands.get(body.id ?? "");
|
|
930
|
-
if (!target) throw new Error("unknown island");
|
|
931
|
-
let framePath = "/";
|
|
932
|
-
if (typeof body.path === "string") {
|
|
933
|
-
if (!isSafeFramePath(body.path)) {
|
|
934
|
-
const env = frameEnvelope(400, { error: "frame failed" });
|
|
935
|
-
res.statusCode = env.status;
|
|
936
|
-
for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
|
|
937
|
-
res.end(env.body);
|
|
938
|
-
return;
|
|
939
|
-
}
|
|
940
|
-
framePath = body.path;
|
|
941
|
-
}
|
|
942
|
-
await server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
|
|
943
|
-
await server.ssrLoadModule(target.file);
|
|
944
|
-
const headers = authorized.identityHeaders;
|
|
945
|
-
const request = new Request(frameScopedUrl(req.url ?? "/", framePath, serverOrigin), {
|
|
946
|
-
method: "POST",
|
|
947
|
-
headers
|
|
948
|
-
});
|
|
949
|
-
const result = await renderServerIslandResult(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn), incomingProps);
|
|
950
|
-
if (Result.isFailure(result)) {
|
|
951
|
-
const err = result.failure;
|
|
952
|
-
if (err.redirect) {
|
|
953
|
-
const env = frameEnvelope(err.status, { redirect: err.redirect });
|
|
954
|
-
res.statusCode = env.status;
|
|
955
|
-
for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
|
|
956
|
-
res.end(env.body);
|
|
957
|
-
return;
|
|
958
|
-
}
|
|
959
|
-
const status = err.status;
|
|
960
|
-
if (status >= 500) console.error("[ilha-router] frame render failed:", err);
|
|
961
|
-
const env = frameEnvelope(status, { error: "frame failed" });
|
|
962
|
-
res.statusCode = env.status;
|
|
963
|
-
for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
|
|
964
|
-
res.end(env.body);
|
|
965
|
-
return;
|
|
966
|
-
}
|
|
967
|
-
const env = frameEnvelope(200, { html: result.success });
|
|
968
|
-
res.statusCode = env.status;
|
|
969
|
-
for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
|
|
970
|
-
res.end(env.body);
|
|
971
|
-
} catch (err) {
|
|
972
|
-
if (err instanceof FrameError && err.redirect) {
|
|
973
|
-
const env = frameEnvelope(err.status, { redirect: err.redirect });
|
|
974
|
-
res.statusCode = env.status;
|
|
975
|
-
for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
|
|
976
|
-
res.end(env.body);
|
|
977
|
-
return;
|
|
978
|
-
}
|
|
979
|
-
const status = err instanceof FrameError ? err.status : 400;
|
|
980
|
-
if (!(err instanceof FrameError) || err.status >= 500) console.error("[ilha-router] frame render failed:", err);
|
|
981
|
-
const env = frameEnvelope(status, { error: "frame failed" });
|
|
982
|
-
res.statusCode = env.status;
|
|
983
|
-
for (const [k, v] of Object.entries(env.headers)) res.setHeader(k, v);
|
|
984
|
-
res.end(env.body);
|
|
985
|
-
}
|
|
986
|
-
});
|
|
987
|
-
const structuralInvalidate = createStructuralInvalidate(state, async () => {
|
|
988
|
-
for (const id of RESOLVED_VIRTUAL_IDS) {
|
|
989
|
-
const mod = server.moduleGraph.getModuleById(id);
|
|
990
|
-
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
991
|
-
}
|
|
992
|
-
server.hot.send({ type: "full-reload" });
|
|
993
|
-
});
|
|
994
|
-
server.watcher.on("add", structuralInvalidate);
|
|
995
|
-
server.watcher.on("addDir", structuralInvalidate);
|
|
996
|
-
server.watcher.on("unlink", structuralInvalidate);
|
|
997
|
-
server.watcher.on("change", async (file) => {
|
|
998
|
-
if (state.shouldRegenOnChange(file)) await structuralInvalidate(file);
|
|
999
|
-
if (!SERVER_FILE_RE.test(file.replace(/\?.*$/, ""))) return;
|
|
1000
|
-
scanCache.delete(file);
|
|
1001
|
-
const graph = server.moduleGraph;
|
|
1002
|
-
let touched = false;
|
|
1003
|
-
graph.forEachModule?.((mod) => {
|
|
1004
|
-
if (mod.id.startsWith("\0ilha:server-island:")) {
|
|
1005
|
-
const m = server.moduleGraph.getModuleById(mod.id);
|
|
1006
|
-
if (m) {
|
|
1007
|
-
server.moduleGraph.invalidateModule(m);
|
|
1008
|
-
touched = true;
|
|
1009
|
-
}
|
|
1010
|
-
}
|
|
1011
|
-
});
|
|
1012
|
-
if (touched) server.hot.send({ type: "full-reload" });
|
|
1013
|
-
});
|
|
1014
|
-
}
|
|
1015
|
-
},
|
|
1016
|
-
rspack(compiler) {
|
|
1017
|
-
state.setPaths(compiler.options.context ?? process.cwd());
|
|
1018
|
-
const structuralInvalidate = createStructuralInvalidate(state, () => {
|
|
1019
|
-
if (!compiler.watching) return;
|
|
1020
|
-
compiler.invalidate();
|
|
1021
|
-
});
|
|
1022
|
-
let closeWatcher;
|
|
1023
|
-
compiler.hooks.watchRun.tap("ilha:pages", () => {
|
|
1024
|
-
closeWatcher?.();
|
|
1025
|
-
closeWatcher = setupRspackPagesWatcher(state, structuralInvalidate);
|
|
1026
|
-
});
|
|
1027
|
-
compiler.hooks.shutdown.tap("ilha:pages", () => closeWatcher?.());
|
|
1028
|
-
}
|
|
1029
|
-
};
|
|
1030
|
-
};
|
|
1031
|
-
const ilhaPages = /* #__PURE__ */ createUnplugin(pagesFactory);
|
|
1032
|
-
|
|
1033
|
-
//#endregion
|
|
1034
|
-
export { ilhaPages as t };
|