@ilha/router 0.8.13 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/codegen.d.ts +3 -0
- package/dist/index.d.ts +28 -3
- package/dist/index.js +2247 -2
- package/dist/{plugin-DdquB3Nf.js → plugin-DogkskcY.js} +510 -15
- package/dist/plugin.d.ts +9 -1
- package/dist/request-scope-C4reU4v0.js +34 -0
- package/dist/request-scope.d.ts +22 -0
- package/dist/rolldown.d.ts +2 -1
- package/dist/rolldown.js +2 -2
- package/dist/rspack.d.ts +1 -0
- package/dist/rspack.js +2 -2
- package/dist/server-island-registry.d.ts +77 -0
- package/dist/server-island-registry.js +134 -0
- package/dist/server-island.d.ts +44 -0
- package/dist/server-island.js +229 -0
- package/dist/server-islands.d.ts +72 -0
- package/dist/ssr.d.ts +17 -88
- package/dist/ssr.js +138 -130
- package/dist/vite.d.ts +1 -0
- package/dist/vite.js +2 -2
- package/package.json +12 -5
- package/dist/src-BKWkRtMx.js +0 -2119
|
@@ -1,14 +1,268 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { t as runWithIslandRequest } from "./request-scope-C4reU4v0.js";
|
|
2
|
+
import { FrameError, getFrameGuard, renderServerIsland, setFrameGuard } from "./server-island-registry.js";
|
|
3
|
+
import { existsSync, readFileSync, statSync, watch } from "node:fs";
|
|
2
4
|
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
3
5
|
import { createUnplugin } from "unplugin";
|
|
4
6
|
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
5
8
|
|
|
9
|
+
//#region src/server-islands.ts
|
|
10
|
+
const EXPORT_RE = /(?:^|\n)\s*export\s+(?:declare\s+)?(?:async\s+)?(?:function\s*\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/g;
|
|
11
|
+
const ISLAND_EXPORT_RE = /(?:^|\n)\s*export\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*ilha\b/g;
|
|
12
|
+
const DEFAULT_ISLAND_RE = /export\s+default\s+ilha\b/;
|
|
13
|
+
const AS_RE = /\.as\(\s*["'`]([a-z][a-z0-9-]*)["'`]\s*\)/;
|
|
14
|
+
function clientRefPublicId(spec, imported) {
|
|
15
|
+
return createHash("sha256").update(`${spec}#${imported}`).digest("base64url");
|
|
16
|
+
}
|
|
17
|
+
function scanClientRefs(source) {
|
|
18
|
+
const used = new Set(Array.from(source.matchAll(/<([A-Z][\w$]*)\b/g), (match) => match[1]));
|
|
19
|
+
const refs = [];
|
|
20
|
+
for (const match of source.matchAll(/(?:^|\n)\s*import\s+(?!type\b)([^'"\n]+?)\s+from\s+["']([^"']+)["']/g)) {
|
|
21
|
+
const clause = match[1].trim();
|
|
22
|
+
const spec = match[2];
|
|
23
|
+
const brace = clause.match(/\{([^}]+)\}/)?.[1];
|
|
24
|
+
if (brace) for (const part of brace.split(",")) {
|
|
25
|
+
const binding = part.trim().match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
26
|
+
if (!binding) continue;
|
|
27
|
+
const imported = binding[1];
|
|
28
|
+
const local = binding[2] ?? imported;
|
|
29
|
+
if (used.has(local)) refs.push({
|
|
30
|
+
id: clientRefPublicId(spec, imported),
|
|
31
|
+
local,
|
|
32
|
+
imported,
|
|
33
|
+
spec
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const defaultLocal = clause.split(",", 1)[0].trim();
|
|
37
|
+
if (/^[A-Za-z_$][\w$]*$/.test(defaultLocal) && used.has(defaultLocal)) refs.push({
|
|
38
|
+
id: clientRefPublicId(spec, "default"),
|
|
39
|
+
local: defaultLocal,
|
|
40
|
+
imported: "default",
|
|
41
|
+
spec
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return refs;
|
|
45
|
+
}
|
|
46
|
+
/** Extract the balanced-paren argument list starting at the "(" following
|
|
47
|
+
* `from` index. String literals are skipped so parens inside them don't count.
|
|
48
|
+
* Returns the inner text, or null when unbalanced within `limit` chars. */
|
|
49
|
+
function extractCallArgs(source, openParen, limit = 4e3) {
|
|
50
|
+
let depth = 0;
|
|
51
|
+
let quote = null;
|
|
52
|
+
for (let i = openParen; i < Math.min(source.length, openParen + limit); i++) {
|
|
53
|
+
const ch = source[i];
|
|
54
|
+
if (quote !== null) {
|
|
55
|
+
if (ch === "\\") i++;
|
|
56
|
+
else if (ch === quote) quote = null;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
60
|
+
quote = ch;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === "(") depth++;
|
|
64
|
+
else if (ch === ")") {
|
|
65
|
+
depth--;
|
|
66
|
+
if (depth === 0) return source.slice(openParen + 1, i);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
console.warn("[ilha-router] scanServerIslands: argument list exceeded the scan limit — call skipped.");
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** First callback body inside an args list: everything after the first top-level
|
|
73
|
+
* comma. Used to scan which module exports a stream/action closure references. */
|
|
74
|
+
function callbackBody(args) {
|
|
75
|
+
let depth = 0;
|
|
76
|
+
let quote = null;
|
|
77
|
+
for (let i = 0; i < args.length; i++) {
|
|
78
|
+
const ch = args[i];
|
|
79
|
+
if (quote !== null) {
|
|
80
|
+
if (ch === "\\") i++;
|
|
81
|
+
else if (ch === quote) quote = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
85
|
+
quote = ch;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
89
|
+
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
|
90
|
+
else if (ch === "," && depth === 0) return args.slice(i + 1);
|
|
91
|
+
}
|
|
92
|
+
return "";
|
|
93
|
+
}
|
|
94
|
+
/** Identifiers in `body` that are members of `candidates`, excluding keywords. */
|
|
95
|
+
function referencedExports(body, candidates) {
|
|
96
|
+
for (const match of body.matchAll(/([A-Za-z_$][\w$]*)\s*\(/g)) if (candidates.has(match[1])) return match[1];
|
|
97
|
+
}
|
|
98
|
+
/** Scan a `*.server.ts(x)` module source for island exports and their
|
|
99
|
+
* declarative wiring. Convention: islands start with `ilha` — both builder
|
|
100
|
+
* chains (`ilha.state()…render()`) and direct factories (`ilha(() => …)`). */
|
|
101
|
+
function scanServerIslands(source) {
|
|
102
|
+
const exports = [];
|
|
103
|
+
for (const match of source.matchAll(EXPORT_RE)) exports.push(match[1]);
|
|
104
|
+
for (const match of source.matchAll(/export\s*\{([^}]*)\}/g)) for (const part of match[1].split(",")) {
|
|
105
|
+
const parts2 = part.trim().split(/\s+as\s+/);
|
|
106
|
+
const name = (parts2.length === 2 ? parts2[1] : parts2[0])?.trim();
|
|
107
|
+
if (name && /^[A-Za-z_$][\w$]*$/.test(name) && !exports.includes(name)) exports.push(name);
|
|
108
|
+
}
|
|
109
|
+
const candidates = new Set(exports);
|
|
110
|
+
const islands = [];
|
|
111
|
+
const collect = (name, start, sliceEnd) => {
|
|
112
|
+
const slice = source.slice(start, sliceEnd);
|
|
113
|
+
const as = slice.match(AS_RE)?.[1] ?? "div";
|
|
114
|
+
const streams = {};
|
|
115
|
+
const actions = {};
|
|
116
|
+
for (const kind of ["stream", "action"]) {
|
|
117
|
+
const re = new RegExp(`\\.${kind}\\s*\\(\\s*["'\`](\\w+)["'\`]\\s*,`, "g");
|
|
118
|
+
for (const match of slice.matchAll(re)) {
|
|
119
|
+
const key = match[1];
|
|
120
|
+
const args = extractCallArgs(slice, (match.index ?? 0) + match[0].indexOf("("));
|
|
121
|
+
if (!args) continue;
|
|
122
|
+
const target = referencedExports(callbackBody(args), candidates);
|
|
123
|
+
if (target) (kind === "stream" ? streams : actions)[key] = target;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
islands.push({
|
|
127
|
+
name,
|
|
128
|
+
as,
|
|
129
|
+
streams,
|
|
130
|
+
actions
|
|
131
|
+
});
|
|
132
|
+
};
|
|
133
|
+
for (const match of source.matchAll(ISLAND_EXPORT_RE)) {
|
|
134
|
+
const name = match[1];
|
|
135
|
+
const rest = source.slice((match.index ?? 0) + match[0].length);
|
|
136
|
+
const nextExport = rest.search(/\nexport\b/);
|
|
137
|
+
collect(name, match.index ?? 0, (match.index ?? 0) + match[0].length + (nextExport === -1 ? rest.length : nextExport));
|
|
138
|
+
}
|
|
139
|
+
const defaultMatch = source.match(DEFAULT_ISLAND_RE);
|
|
140
|
+
if (defaultMatch && defaultMatch.index !== void 0) {
|
|
141
|
+
const rest = source.slice(defaultMatch.index + defaultMatch[0].length);
|
|
142
|
+
const nextExport = rest.search(/\nexport\b/);
|
|
143
|
+
collect("default", defaultMatch.index, defaultMatch.index + defaultMatch[0].length + (nextExport === -1 ? rest.length : nextExport));
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
islands,
|
|
147
|
+
exports,
|
|
148
|
+
clientRefs: scanClientRefs(source),
|
|
149
|
+
clientLoader: /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/.test(source)
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
function loadServerModuleScan(path) {
|
|
153
|
+
return scanServerIslands(readFileSync(path, "utf8"));
|
|
154
|
+
}
|
|
155
|
+
/** Virtual-module id prefix for generated client proxies of server islands.
|
|
156
|
+
* The file path rides base64url-encoded: a raw suffix like
|
|
157
|
+
* `\0…:…/tasks.server.tsx` would end in `.server.*` and oxidejs's client-stub
|
|
158
|
+
* loader would claim the virtual module before us. */
|
|
159
|
+
const SERVER_ISLAND_PREFIX = "\0ilha:server-island:";
|
|
160
|
+
/** Virtual-module specifier serving the client proxy for one server island file. */
|
|
161
|
+
function serverIslandVirtualSpec(file) {
|
|
162
|
+
return SERVER_ISLAND_PREFIX + Buffer.from(file).toString("base64url");
|
|
163
|
+
}
|
|
164
|
+
/** Emit the client virtual module for one scanned server file. Plain JS —
|
|
165
|
+
* `\0` virtual modules bypass Vite's built-in TS transform, so type-only
|
|
166
|
+
* constructs here would reach the browser unparsed. Editor types are
|
|
167
|
+
* unaffected: TS resolves the ORIGINAL specifier (the real server module);
|
|
168
|
+
* this module exists only inside the client bundle. Frames are fetched from
|
|
169
|
+
* the plugin's `/__ilha/frame` dev middleware. */
|
|
170
|
+
function serverIslandPublicId(spec, name) {
|
|
171
|
+
return createHash("sha256").update(`${spec}#${name}`).digest("base64url");
|
|
172
|
+
}
|
|
173
|
+
function generateServerIslandModule(spec, scan) {
|
|
174
|
+
const moduleKey = basename(spec).replace(/\.server\.(?:[jt]sx?)$/i, "");
|
|
175
|
+
const lines = [
|
|
176
|
+
`import { client as $$rpc } from "virtual:oxide/client";`,
|
|
177
|
+
`import { __ilhaServerIsland } from "@ilha/router/server-island";`,
|
|
178
|
+
`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(moduleKey)}][method](args.slice(0, -1), opts) : $$rpc[${JSON.stringify(moduleKey)}][method](args); };`,
|
|
179
|
+
...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)};`)
|
|
180
|
+
];
|
|
181
|
+
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);`);
|
|
182
|
+
for (const island of scan.islands) {
|
|
183
|
+
const wiring = [];
|
|
184
|
+
const streams = Object.entries(island.streams).map(([key, target]) => `${JSON.stringify(key)}: (signal) => $$call(${JSON.stringify(target)}, [{ signal }])`);
|
|
185
|
+
const actions = Object.entries(island.actions).map(([key, target]) => `${JSON.stringify(key)}: (...args) => $$call(${JSON.stringify(target)}, args)`);
|
|
186
|
+
if (streams.length) wiring.push(`streams: { ${streams.join(", ")} }`);
|
|
187
|
+
if (actions.length) wiring.push(`actions: { ${actions.join(", ")} }`);
|
|
188
|
+
if (scan.clientRefs.length) wiring.push(`children: { ${scan.clientRefs.map((ref, index) => `${JSON.stringify(ref.id)}: $$child${index}`).join(", ")} }`);
|
|
189
|
+
const id = serverIslandPublicId(spec, island.name);
|
|
190
|
+
wiring.push(`frame: () => fetch("/__ilha/frame", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: ${JSON.stringify(id)}, path: location.pathname + location.search }) }).then((r) => { if (!r.ok) throw new Error("frame failed"); return r.json(); }).then((j) => { if (j.redirect) { location.assign(j.redirect); throw new Error("frame redirected"); } return j.html; })`);
|
|
191
|
+
if (scan.clientLoader) wiring.push(`clientLoader: () => $$call("load", [])`);
|
|
192
|
+
const call = `__ilhaServerIsland(${JSON.stringify(id)}, ${JSON.stringify(island.as)}, { ${wiring.join(", ")} })`;
|
|
193
|
+
if (island.name === "default") lines.push(`export default ${call};`);
|
|
194
|
+
else lines.push(`export const ${island.name} = ${call};`);
|
|
195
|
+
}
|
|
196
|
+
return lines.join("\n");
|
|
197
|
+
}
|
|
198
|
+
function parseImportClause(clause) {
|
|
199
|
+
const result = { named: [] };
|
|
200
|
+
let rest = clause.trim();
|
|
201
|
+
const nsMatch = rest.match(/\*\s+as\s+([A-Za-z_$][\w$]*)/);
|
|
202
|
+
if (nsMatch) {
|
|
203
|
+
result.namespace = nsMatch[1];
|
|
204
|
+
rest = rest.replace(nsMatch[0], "").replace(/,/g, "").trim();
|
|
205
|
+
}
|
|
206
|
+
const braceStart = rest.indexOf("{");
|
|
207
|
+
if (braceStart !== -1) {
|
|
208
|
+
const before = rest.slice(0, braceStart).replace(/,/g, "").trim();
|
|
209
|
+
if (before) result.defaultLocal = before;
|
|
210
|
+
const inner = rest.slice(braceStart + 1, rest.lastIndexOf("}"));
|
|
211
|
+
for (const item of inner.split(",")) {
|
|
212
|
+
const trimmed = item.trim();
|
|
213
|
+
if (!trimmed || trimmed.startsWith("type ")) continue;
|
|
214
|
+
const asMatch = trimmed.match(/^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/);
|
|
215
|
+
if (asMatch) result.named.push({
|
|
216
|
+
imported: asMatch[1],
|
|
217
|
+
local: asMatch[2] ?? asMatch[1]
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
} else if (rest && !result.namespace) result.defaultLocal = rest.replace(/,/g, "").trim();
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Rewrite import sites whose specifier targets a server module containing
|
|
225
|
+
* island exports. Island bindings move to the virtual proxy module; all other
|
|
226
|
+
* bindings stay on the original specifier (oxidejs replaces them with tacho
|
|
227
|
+
* stubs). Returns null when no statement needed rewriting.
|
|
228
|
+
*/
|
|
229
|
+
function splitServerImports(code, ctx) {
|
|
230
|
+
const IMPORT_RE = /(^|\n)import\s+(?!type\b)([^'"\n]+?)\s*from\s*(["'])([^"'\n]+)\3;?/g;
|
|
231
|
+
let changed = false;
|
|
232
|
+
const out = code.replace(IMPORT_RE, (statement, lead, clause, _q, spec) => {
|
|
233
|
+
const info = ctx.islandNamesFor(spec);
|
|
234
|
+
if (!info) return statement;
|
|
235
|
+
const parsed = parseImportClause(clause);
|
|
236
|
+
const routed = [];
|
|
237
|
+
const kept = [];
|
|
238
|
+
for (const binding of parsed.named) routed.push(binding);
|
|
239
|
+
const routeDefault = parsed.defaultLocal !== void 0 && info.hasDefault;
|
|
240
|
+
if (routed.length === 0 && !routeDefault) return statement;
|
|
241
|
+
changed = true;
|
|
242
|
+
const parts = [];
|
|
243
|
+
const keptBits = [];
|
|
244
|
+
if (!routeDefault && parsed.defaultLocal) keptBits.push(parsed.defaultLocal);
|
|
245
|
+
if (kept.length) keptBits.push(`{ ${kept.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
|
|
246
|
+
if (keptBits.length) parts.push(`import ${keptBits.join(", ")} from ${JSON.stringify(spec)};`);
|
|
247
|
+
if (parsed.namespace) parts.push(`import * as ${parsed.namespace} from ${JSON.stringify(spec)};`);
|
|
248
|
+
const routedBits = [];
|
|
249
|
+
if (routeDefault) routedBits.push(parsed.defaultLocal);
|
|
250
|
+
if (routed.length) routedBits.push(`{ ${routed.map((b) => b.local === b.imported ? b.imported : `${b.imported} as ${b.local}`).join(", ")} }`);
|
|
251
|
+
parts.push(`import ${routedBits.join(", ")} from ${JSON.stringify(ctx.virtualSpecFor(spec))};`);
|
|
252
|
+
return lead + parts.join("\n");
|
|
253
|
+
});
|
|
254
|
+
return changed ? out : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
6
258
|
//#region src/codegen.ts
|
|
7
259
|
function toPosix(p) {
|
|
8
260
|
return p.replace(/\\/g, "/");
|
|
9
261
|
}
|
|
10
262
|
/** Files that should never be treated as pages even if they match the ts/tsx extension. */
|
|
11
263
|
const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
|
|
264
|
+
/** Server pages: `foo.server.tsx` routes `/foo`, rendered through the frame protocol. */
|
|
265
|
+
const SERVER_PAGE_RE = /\.server\.(ts|tsx)$/;
|
|
12
266
|
/**
|
|
13
267
|
* Match a top-of-statement `export const load`, `export let load`,
|
|
14
268
|
* `export function load`, or `export async function load`. Intentionally
|
|
@@ -16,20 +270,22 @@ const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
|
|
|
16
270
|
* in v1. Declare `load` directly in the file to be picked up.
|
|
17
271
|
*/
|
|
18
272
|
const LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+load\b/m;
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
273
|
+
/** `export const load = loader.client(…)` — the only client-loader form. */
|
|
274
|
+
/** `export const load = loader.client(…)` — client loader under the server name. */
|
|
275
|
+
const LOAD_CLIENT_EXPORT_RE = /(^|\n)\s*export\s+(?:const|let|var)\s+load\b\s*=\s*loader\.client\b/m;
|
|
21
276
|
async function detectLoaderExports(file) {
|
|
22
277
|
try {
|
|
23
278
|
const stripped = (await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "");
|
|
279
|
+
const isClientLoader = LOAD_CLIENT_EXPORT_RE.test(stripped);
|
|
24
280
|
return {
|
|
25
|
-
load: LOADER_EXPORT_RE.test(stripped),
|
|
26
|
-
|
|
281
|
+
load: LOADER_EXPORT_RE.test(stripped) && !isClientLoader,
|
|
282
|
+
isClientLoader
|
|
27
283
|
};
|
|
28
284
|
} catch (err) {
|
|
29
285
|
if (err?.code !== "ENOENT") console.warn(`[ilha-router] failed to read ${file} while detecting loader exports:`, err);
|
|
30
286
|
return {
|
|
31
287
|
load: false,
|
|
32
|
-
|
|
288
|
+
isClientLoader: false
|
|
33
289
|
};
|
|
34
290
|
}
|
|
35
291
|
}
|
|
@@ -45,7 +301,9 @@ function dirToSegment(name) {
|
|
|
45
301
|
}
|
|
46
302
|
function fileToPattern(pagesDir, file) {
|
|
47
303
|
const rel = toPosix(relative(pagesDir, file));
|
|
48
|
-
|
|
304
|
+
let noExt = rel.slice(0, -extname(rel).length);
|
|
305
|
+
if (SERVER_PAGE_RE.test(rel)) noExt = noExt.replace(/\.server$/, "");
|
|
306
|
+
const parts = noExt.split("/");
|
|
49
307
|
const segments = [...parts.slice(0, -1).map(dirToSegment), fileToSegment(parts.at(-1))];
|
|
50
308
|
if (segments.at(-1) === "index") segments.pop();
|
|
51
309
|
return "/" + segments.filter(Boolean).join("/") || "/";
|
|
@@ -116,12 +374,13 @@ async function scanPages(pagesDir) {
|
|
|
116
374
|
return cached;
|
|
117
375
|
};
|
|
118
376
|
return Promise.all(pages.map(async (file) => {
|
|
377
|
+
const server = SERVER_PAGE_RE.test(basename(file));
|
|
119
378
|
const pattern = fileToPattern(pagesDir, file);
|
|
120
379
|
const layouts = chainForFile(pagesDir, file, allSet, "+layout");
|
|
121
380
|
const errors = chainForFile(pagesDir, file, allSet, "+error");
|
|
122
381
|
const [pageExports, ...layoutExports] = await Promise.all([detectLoaderExports(file), ...layouts.map(getLayoutExports)]);
|
|
123
382
|
const loaderLayouts = layouts.filter((_, i) => layoutExports[i].load);
|
|
124
|
-
const clientLoaderLayouts = layouts.filter((_, i) => layoutExports[i].
|
|
383
|
+
const clientLoaderLayouts = layouts.filter((_, i) => layoutExports[i].isClientLoader);
|
|
125
384
|
return {
|
|
126
385
|
file,
|
|
127
386
|
pattern,
|
|
@@ -130,8 +389,9 @@ async function scanPages(pagesDir) {
|
|
|
130
389
|
errors,
|
|
131
390
|
hasLoader: pageExports.load,
|
|
132
391
|
loaderLayouts,
|
|
133
|
-
hasClientLoader: pageExports.
|
|
134
|
-
clientLoaderLayouts
|
|
392
|
+
hasClientLoader: pageExports.isClientLoader,
|
|
393
|
+
clientLoaderLayouts,
|
|
394
|
+
server
|
|
135
395
|
};
|
|
136
396
|
}));
|
|
137
397
|
}
|
|
@@ -144,6 +404,12 @@ function validateEntries(entries, pagesDir, strict) {
|
|
|
144
404
|
const seenNames = /* @__PURE__ */ new Map();
|
|
145
405
|
const problems = [];
|
|
146
406
|
for (const entry of entries) {
|
|
407
|
+
if (entry.server) try {
|
|
408
|
+
if (!loadServerModuleScan(entry.file).islands.some((island) => island.name === "default")) throw new Error(`[ilha:pages] Server page ${entry.file} has no default island export.\n A .server page must "export default ilha…" so it can be rendered server-side.`);
|
|
409
|
+
} catch (err) {
|
|
410
|
+
if (err instanceof Error && err.message.includes("[ilha:pages]")) throw err;
|
|
411
|
+
throw new Error(`[ilha:pages] Server page ${entry.file} could not be scanned for island exports.`);
|
|
412
|
+
}
|
|
147
413
|
const existingPattern = seenPatterns.get(entry.pattern);
|
|
148
414
|
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.`);
|
|
149
415
|
else seenPatterns.set(entry.pattern, entry.file);
|
|
@@ -206,6 +472,7 @@ function buildServerFile(entries, serverFile) {
|
|
|
206
472
|
`// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
|
|
207
473
|
``,
|
|
208
474
|
...imports,
|
|
475
|
+
...entries.some((e) => e.hasLoader || e.loaderLayouts.length > 0) ? [`import { setFrameLoaderRunner } from "@ilha/router/server-island-registry";`, `setFrameLoaderRunner((path) => pageRouter.runLoader(path));`] : [],
|
|
209
476
|
``,
|
|
210
477
|
...wrappedIslandLines,
|
|
211
478
|
``,
|
|
@@ -232,6 +499,22 @@ function buildClientFile(entries, clientFile, opts) {
|
|
|
232
499
|
const registryLines = [];
|
|
233
500
|
const routeLines = [];
|
|
234
501
|
for (const [i, entry] of entries.entries()) {
|
|
502
|
+
if (entry.server) {
|
|
503
|
+
imports.push(`import { default as _page${i} } from ${JSON.stringify(serverIslandVirtualSpec(entry.file))};`);
|
|
504
|
+
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
|
|
505
|
+
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
|
|
506
|
+
let serverExpr = `_page${i}`;
|
|
507
|
+
for (let j = entry.errors.length - 1; j >= 0; j--) serverExpr = `wrapError(_error${i}_${j}, ${serverExpr})`;
|
|
508
|
+
for (let j = entry.layouts.length - 1; j >= 0; j--) serverExpr = `wrapLayout(_layout${i}_${j}, ${serverExpr})`;
|
|
509
|
+
const wrappedServerId = `_wrapped${i}`;
|
|
510
|
+
wrappedIslandLines.push(`const ${wrappedServerId} = ${serverExpr};`);
|
|
511
|
+
registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedServerId}` + (i < entries.length - 1 ? "," : ""));
|
|
512
|
+
if (!isStatic) {
|
|
513
|
+
routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedServerId})`);
|
|
514
|
+
if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
|
|
515
|
+
}
|
|
516
|
+
continue;
|
|
517
|
+
}
|
|
235
518
|
imports.push(`import { default as _page${i} } from ${JSON.stringify(clientImport(entry.file))};`);
|
|
236
519
|
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
|
|
237
520
|
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
|
|
@@ -246,12 +529,12 @@ function buildClientFile(entries, clientFile, opts) {
|
|
|
246
529
|
const clientLoaderIds = [];
|
|
247
530
|
for (const [j, layout] of entry.clientLoaderLayouts.entries()) {
|
|
248
531
|
const id = `_cl${i}_l${j}`;
|
|
249
|
-
imports.push(`import {
|
|
532
|
+
imports.push(`import { load as ${id} } from ${JSON.stringify(clientLoaderImport(layout))};`);
|
|
250
533
|
clientLoaderIds.push(id);
|
|
251
534
|
}
|
|
252
535
|
if (entry.hasClientLoader) {
|
|
253
536
|
const id = `_cl${i}`;
|
|
254
|
-
imports.push(`import {
|
|
537
|
+
imports.push(`import { load as ${id} } from ${JSON.stringify(clientLoaderImport(entry.file))};`);
|
|
255
538
|
clientLoaderIds.push(id);
|
|
256
539
|
}
|
|
257
540
|
if (clientLoaderIds.length > 0) {
|
|
@@ -375,8 +658,38 @@ const RESOLVED_VIRTUAL_IDS = [
|
|
|
375
658
|
];
|
|
376
659
|
/** Query suffix used on page/layout imports in the client file. */
|
|
377
660
|
const CLIENT_QUERY = "?client";
|
|
378
|
-
/** Query suffix that re-exports a page/layout's `
|
|
661
|
+
/** Query suffix that re-exports a page/layout's `load` (loader.client) for the browser bundle. */
|
|
379
662
|
const CLIENT_LOADER_QUERY = "?client-loader";
|
|
663
|
+
function decodeServerIslandId(id) {
|
|
664
|
+
if (!id.startsWith("\0ilha:server-island:")) return null;
|
|
665
|
+
try {
|
|
666
|
+
return Buffer.from(id.slice(SERVER_ISLAND_PREFIX.length), "base64url").toString();
|
|
667
|
+
} catch {
|
|
668
|
+
return null;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
const SERVER_FILE_RE = /\.server\.(ts|tsx|js|jsx)$/;
|
|
672
|
+
/** Cheap prefilter — most modules never mention a server import. Extension
|
|
673
|
+
* optional: aliases like `$lib/tasks.server` resolve to `.server.tsx` later. */
|
|
674
|
+
const SERVER_SPEC_HINT_RE = /["'][^"']*\.server(\.[cm]?[jt]sx?)?["']/;
|
|
675
|
+
/** mtime-keyed scan cache so repeated transforms don't re-read/re-parse. */
|
|
676
|
+
const scanCache = /* @__PURE__ */ new Map();
|
|
677
|
+
function scanFor(path) {
|
|
678
|
+
try {
|
|
679
|
+
const mtimeMs = statSync(path).mtimeMs;
|
|
680
|
+
const cached = scanCache.get(path);
|
|
681
|
+
if (cached && cached.mtimeMs === mtimeMs) return cached.scan;
|
|
682
|
+
const scan = loadServerModuleScan(path);
|
|
683
|
+
if (scan.islands.length === 0) return null;
|
|
684
|
+
scanCache.set(path, {
|
|
685
|
+
mtimeMs,
|
|
686
|
+
scan
|
|
687
|
+
});
|
|
688
|
+
return scan;
|
|
689
|
+
} catch {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
380
693
|
/** Read & parse a package.json, returning null on any error. */
|
|
381
694
|
function readJson(path) {
|
|
382
695
|
try {
|
|
@@ -515,7 +828,7 @@ function loadPagesModule(state, id) {
|
|
|
515
828
|
}
|
|
516
829
|
if (id.endsWith("?client-loader")) {
|
|
517
830
|
const bare = id.slice(0, -14);
|
|
518
|
-
return `export {
|
|
831
|
+
return `export { load } from ${JSON.stringify(bare)};`;
|
|
519
832
|
}
|
|
520
833
|
if (id.endsWith("?client")) {
|
|
521
834
|
const bare = id.slice(0, -7);
|
|
@@ -558,6 +871,7 @@ function setupRspackPagesWatcher(state, structuralInvalidate) {
|
|
|
558
871
|
}
|
|
559
872
|
const pagesFactory = (options = {}) => {
|
|
560
873
|
const state = createPagesPluginState(options);
|
|
874
|
+
const serverIslands = /* @__PURE__ */ new Map();
|
|
561
875
|
return {
|
|
562
876
|
name: "ilha:pages",
|
|
563
877
|
async buildStart() {
|
|
@@ -569,9 +883,19 @@ const pagesFactory = (options = {}) => {
|
|
|
569
883
|
await regenFromPagesChange(state, file, (f) => state.shouldRegenOnChange(f));
|
|
570
884
|
},
|
|
571
885
|
resolveId(id, importer) {
|
|
886
|
+
if (id.startsWith("\0ilha:server-island:")) return id;
|
|
572
887
|
return resolvePagesId(state, id, importer);
|
|
573
888
|
},
|
|
574
889
|
load(id) {
|
|
890
|
+
const islandFile = decodeServerIslandId(id);
|
|
891
|
+
if (islandFile !== null) {
|
|
892
|
+
const scan = loadServerModuleScan(islandFile);
|
|
893
|
+
for (const island of scan.islands) serverIslands.set(serverIslandPublicId(islandFile, island.name), {
|
|
894
|
+
file: islandFile,
|
|
895
|
+
name: island.name
|
|
896
|
+
});
|
|
897
|
+
return generateServerIslandModule(islandFile, scan);
|
|
898
|
+
}
|
|
575
899
|
return loadPagesModule(state, id);
|
|
576
900
|
},
|
|
577
901
|
vite: {
|
|
@@ -584,7 +908,7 @@ const pagesFactory = (options = {}) => {
|
|
|
584
908
|
...detectIlhaConsumers(userConfig.root ? resolve(userConfig.root) : process.cwd())
|
|
585
909
|
];
|
|
586
910
|
const existingNoExternal = userConfig.ssr?.noExternal;
|
|
587
|
-
const noExternal = existingNoExternal === true ? true : [.../* @__PURE__ */ new Set([...Array.isArray(existingNoExternal) ? existingNoExternal : existingNoExternal
|
|
911
|
+
const noExternal = existingNoExternal === true ? true : [.../* @__PURE__ */ new Set([...Array.isArray(existingNoExternal) ? existingNoExternal : existingNoExternal == null ? [] : [existingNoExternal], ...singletonPeers])];
|
|
588
912
|
return {
|
|
589
913
|
resolve: { dedupe: [.../* @__PURE__ */ new Set([...userConfig.resolve?.dedupe ?? [], ...singletonPeers])] },
|
|
590
914
|
ssr: { noExternal },
|
|
@@ -604,8 +928,165 @@ const pagesFactory = (options = {}) => {
|
|
|
604
928
|
configResolved(config) {
|
|
605
929
|
state.setPaths(config.root);
|
|
606
930
|
},
|
|
931
|
+
async transform(code, id, opts) {
|
|
932
|
+
const file = id.replace(/\?.*$/, "");
|
|
933
|
+
const serverFile = SERVER_FILE_RE.test(file);
|
|
934
|
+
if (opts?.ssr) {
|
|
935
|
+
if (!serverFile) return null;
|
|
936
|
+
const scan = scanFor(file);
|
|
937
|
+
const lines = [];
|
|
938
|
+
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)};`);
|
|
939
|
+
if (scan && scan.islands.length > 0 && !code.startsWith("// oxidejs:client-stub")) {
|
|
940
|
+
lines.unshift(`import * as __ilhaSelf from ${JSON.stringify(file)};`);
|
|
941
|
+
lines.unshift(`import { registerServerIsland } from "@ilha/router/server-island-registry";`);
|
|
942
|
+
for (const island of scan.islands) {
|
|
943
|
+
const id2 = serverIslandPublicId(file, island.name);
|
|
944
|
+
const isServerPage = SERVER_PAGE_RE.test(file) && state.isUnderPagesDir(file) && scan.exports.includes("load") ? `, { load: __ilhaSelf.load, pattern: ${JSON.stringify(fileToPattern(state.pagesDir, file))} }` : "";
|
|
945
|
+
lines.push(`registerServerIsland(${JSON.stringify(id2)}, () => __ilhaSelf[${JSON.stringify(island.name)}]?.[Symbol.for("ilha.renderState")]${isServerPage});`);
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
if (lines.length === 0) return null;
|
|
949
|
+
return `${code}\n${lines.join("\n")}`;
|
|
950
|
+
}
|
|
951
|
+
if (id.startsWith("\0") || id.includes("node_modules")) return null;
|
|
952
|
+
if (serverFile) return null;
|
|
953
|
+
if (!SERVER_SPEC_HINT_RE.test(code)) return null;
|
|
954
|
+
const SPEC_RE = /(?:^|\n)\s*import\s+(?:type\s+)?[^'"\n]+?\s*from\s*["']([^"']+)["']/g;
|
|
955
|
+
const specs = /* @__PURE__ */ new Set();
|
|
956
|
+
for (const match of code.matchAll(SPEC_RE)) specs.add(match[1]);
|
|
957
|
+
if (specs.size === 0) return null;
|
|
958
|
+
const scanned = /* @__PURE__ */ new Map();
|
|
959
|
+
for (const spec of specs) {
|
|
960
|
+
let file;
|
|
961
|
+
try {
|
|
962
|
+
const resolved = await this.resolve?.(spec, id);
|
|
963
|
+
file = resolved?.path ?? resolved?.id;
|
|
964
|
+
} catch {
|
|
965
|
+
file = void 0;
|
|
966
|
+
}
|
|
967
|
+
if (!file && spec.startsWith("/")) file = spec;
|
|
968
|
+
if (!file || !SERVER_FILE_RE.test(file.replace(/\?.*$/, ""))) continue;
|
|
969
|
+
const scan = scanFor(file);
|
|
970
|
+
if (!scan) continue;
|
|
971
|
+
scanned.set(spec, {
|
|
972
|
+
file,
|
|
973
|
+
islands: new Set(scan.islands.filter((i) => i.name !== "default").map((i) => i.name)),
|
|
974
|
+
hasDefault: scan.islands.some((i) => i.name === "default")
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
if (scanned.size === 0) return null;
|
|
978
|
+
return splitServerImports(code, {
|
|
979
|
+
islandNamesFor: (spec) => {
|
|
980
|
+
const entry = scanned.get(spec);
|
|
981
|
+
return entry ? {
|
|
982
|
+
islands: entry.islands,
|
|
983
|
+
hasDefault: entry.hasDefault
|
|
984
|
+
} : null;
|
|
985
|
+
},
|
|
986
|
+
virtualSpecFor: (spec) => serverIslandVirtualSpec(scanned.get(spec).file)
|
|
987
|
+
});
|
|
988
|
+
},
|
|
607
989
|
configureServer(server) {
|
|
608
990
|
server.watcher.add(state.pagesDir);
|
|
991
|
+
if (options.frameGuard) setFrameGuard(options.frameGuard);
|
|
992
|
+
server.middlewares.use(async (req, res, next) => {
|
|
993
|
+
if ((req.url ?? "").split("?")[0] !== "/__ilha/frame") return next();
|
|
994
|
+
if (req.method !== "POST") {
|
|
995
|
+
res.statusCode = 405;
|
|
996
|
+
res.end();
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (!(req.headers["content-type"] ?? "").startsWith("application/json")) {
|
|
1000
|
+
res.statusCode = 415;
|
|
1001
|
+
res.end();
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
const guardHeaders = new Headers();
|
|
1006
|
+
for (const name of [
|
|
1007
|
+
"cookie",
|
|
1008
|
+
"authorization",
|
|
1009
|
+
"x-forwarded-for"
|
|
1010
|
+
]) {
|
|
1011
|
+
const v = req.headers[name];
|
|
1012
|
+
if (typeof v === "string") guardHeaders.set(name, v);
|
|
1013
|
+
}
|
|
1014
|
+
const denied = await getFrameGuard()?.(new Request(`http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`, {
|
|
1015
|
+
method: req.method,
|
|
1016
|
+
headers: guardHeaders
|
|
1017
|
+
}));
|
|
1018
|
+
if (denied) {
|
|
1019
|
+
res.statusCode = denied.status;
|
|
1020
|
+
res.setHeader("cache-control", "no-store");
|
|
1021
|
+
res.end();
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
} catch {
|
|
1025
|
+
res.statusCode = 403;
|
|
1026
|
+
res.end();
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
const origin = req.headers.origin;
|
|
1030
|
+
const host = req.headers.host;
|
|
1031
|
+
if (origin && origin !== `http://${host}` && origin !== `https://${host}`) {
|
|
1032
|
+
res.statusCode = 403;
|
|
1033
|
+
res.end();
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
const chunks = [];
|
|
1037
|
+
let size = 0;
|
|
1038
|
+
for await (const chunk of req) {
|
|
1039
|
+
size += chunk.length;
|
|
1040
|
+
if (size > 16384) {
|
|
1041
|
+
res.statusCode = 413;
|
|
1042
|
+
res.end();
|
|
1043
|
+
return;
|
|
1044
|
+
}
|
|
1045
|
+
chunks.push(chunk);
|
|
1046
|
+
}
|
|
1047
|
+
try {
|
|
1048
|
+
const body = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
1049
|
+
const target = serverIslands.get(body.id ?? "");
|
|
1050
|
+
if (!target) throw new Error("unknown island");
|
|
1051
|
+
let framePath = "/__ilha/frame";
|
|
1052
|
+
if (typeof body.path === "string" && body.path.startsWith("/") && !body.path.includes("//") && body.path.length <= 2048) framePath = body.path;
|
|
1053
|
+
await server.ssrLoadModule(VIRTUAL_PAGES_SERVER);
|
|
1054
|
+
if (typeof (await server.ssrLoadModule(target.file))[target.name]?.[Symbol.for("ilha.renderState")] !== "function") throw new Error("unknown island");
|
|
1055
|
+
const headers = new Headers();
|
|
1056
|
+
for (const name of [
|
|
1057
|
+
"cookie",
|
|
1058
|
+
"authorization",
|
|
1059
|
+
"user-agent",
|
|
1060
|
+
"x-forwarded-for"
|
|
1061
|
+
]) {
|
|
1062
|
+
const value = req.headers[name];
|
|
1063
|
+
if (typeof value === "string") headers.set(name, value);
|
|
1064
|
+
}
|
|
1065
|
+
const requestOrigin = `http://${req.headers.host ?? "localhost"}`;
|
|
1066
|
+
const request = new Request(new URL(framePath, requestOrigin), {
|
|
1067
|
+
method: "POST",
|
|
1068
|
+
headers
|
|
1069
|
+
});
|
|
1070
|
+
const html = await renderServerIsland(body.id ?? "", request, (r, fn) => runWithIslandRequest(r, fn));
|
|
1071
|
+
res.setHeader("cache-control", "no-store");
|
|
1072
|
+
res.setHeader("content-type", "application/json;charset=utf-8");
|
|
1073
|
+
res.end(JSON.stringify({ html: String(html) }));
|
|
1074
|
+
} catch (err) {
|
|
1075
|
+
if (err instanceof FrameError && err.redirect) {
|
|
1076
|
+
res.statusCode = err.status;
|
|
1077
|
+
res.setHeader("cache-control", "no-store");
|
|
1078
|
+
res.setHeader("content-type", "application/json;charset=utf-8");
|
|
1079
|
+
res.end(JSON.stringify({ redirect: err.redirect }));
|
|
1080
|
+
return;
|
|
1081
|
+
}
|
|
1082
|
+
const status = err instanceof FrameError ? err.status : 400;
|
|
1083
|
+
if (!(err instanceof FrameError) || err.status >= 500) console.error("[ilha-router] frame render failed:", err);
|
|
1084
|
+
res.statusCode = status;
|
|
1085
|
+
res.setHeader("cache-control", "no-store");
|
|
1086
|
+
res.setHeader("content-type", "application/json;charset=utf-8");
|
|
1087
|
+
res.end(JSON.stringify({ error: "frame failed" }));
|
|
1088
|
+
}
|
|
1089
|
+
});
|
|
609
1090
|
const structuralInvalidate = createStructuralInvalidate(state, async () => {
|
|
610
1091
|
for (const id of RESOLVED_VIRTUAL_IDS) {
|
|
611
1092
|
const mod = server.moduleGraph.getModuleById(id);
|
|
@@ -618,6 +1099,20 @@ const pagesFactory = (options = {}) => {
|
|
|
618
1099
|
server.watcher.on("unlink", structuralInvalidate);
|
|
619
1100
|
server.watcher.on("change", async (file) => {
|
|
620
1101
|
if (state.shouldRegenOnChange(file)) await structuralInvalidate(file);
|
|
1102
|
+
if (!SERVER_FILE_RE.test(file.replace(/\?.*$/, ""))) return;
|
|
1103
|
+
scanCache.delete(file);
|
|
1104
|
+
const graph = server.moduleGraph;
|
|
1105
|
+
let touched = false;
|
|
1106
|
+
graph.forEachModule?.((mod) => {
|
|
1107
|
+
if (mod.id.startsWith("\0ilha:server-island:")) {
|
|
1108
|
+
const m = server.moduleGraph.getModuleById(mod.id);
|
|
1109
|
+
if (m) {
|
|
1110
|
+
server.moduleGraph.invalidateModule(m);
|
|
1111
|
+
touched = true;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
if (touched) server.hot.send({ type: "full-reload" });
|
|
621
1116
|
});
|
|
622
1117
|
}
|
|
623
1118
|
},
|