@praxisjs/ssg 0.1.0
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/CHANGELOG.md +22 -0
- package/LICENSE +21 -0
- package/dist/__tests__/loader-hook.test.d.ts +2 -0
- package/dist/__tests__/loader-hook.test.d.ts.map +1 -0
- package/dist/__tests__/loader-hook.test.js +66 -0
- package/dist/__tests__/loader-hook.test.js.map +1 -0
- package/dist/__tests__/plugin.test.d.ts +2 -0
- package/dist/__tests__/plugin.test.d.ts.map +1 -0
- package/dist/__tests__/plugin.test.js +128 -0
- package/dist/__tests__/plugin.test.js.map +1 -0
- package/dist/__tests__/prerender.test.d.ts +2 -0
- package/dist/__tests__/prerender.test.d.ts.map +1 -0
- package/dist/__tests__/prerender.test.js +211 -0
- package/dist/__tests__/prerender.test.js.map +1 -0
- package/dist/__tests__/register-fallback.test.d.ts +2 -0
- package/dist/__tests__/register-fallback.test.d.ts.map +1 -0
- package/dist/__tests__/register-fallback.test.js +20 -0
- package/dist/__tests__/register-fallback.test.js.map +1 -0
- package/dist/__tests__/resolve-sync.test.d.ts +2 -0
- package/dist/__tests__/resolve-sync.test.d.ts.map +1 -0
- package/dist/__tests__/resolve-sync.test.js +67 -0
- package/dist/__tests__/resolve-sync.test.js.map +1 -0
- package/dist/__tests__/routes.test.d.ts +2 -0
- package/dist/__tests__/routes.test.d.ts.map +1 -0
- package/dist/__tests__/routes.test.js +154 -0
- package/dist/__tests__/routes.test.js.map +1 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +156 -0
- package/dist/index.js.map +1 -0
- package/dist/loader-hook.d.ts +5 -0
- package/dist/loader-hook.d.ts.map +1 -0
- package/dist/loader-hook.js +34 -0
- package/dist/loader-hook.js.map +1 -0
- package/dist/prerender.d.ts +38 -0
- package/dist/prerender.d.ts.map +1 -0
- package/dist/prerender.js +116 -0
- package/dist/prerender.js.map +1 -0
- package/dist/routes.d.ts +32 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +59 -0
- package/dist/routes.js.map +1 -0
- package/package.json +38 -0
- package/src/__tests__/loader-hook.test.ts +76 -0
- package/src/__tests__/plugin.test.ts +161 -0
- package/src/__tests__/prerender.test.ts +181 -0
- package/src/__tests__/register-fallback.test.ts +23 -0
- package/src/__tests__/resolve-sync.test.ts +91 -0
- package/src/__tests__/routes.test.ts +175 -0
- package/src/index.ts +233 -0
- package/src/loader-hook.ts +43 -0
- package/src/prerender.ts +168 -0
- package/src/routes.ts +83 -0
- package/tsconfig.json +11 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { ResolveFnOutput, ResolveHookContext } from "node:module";
|
|
4
|
+
|
|
5
|
+
import { registerLoaderHook } from "../index";
|
|
6
|
+
|
|
7
|
+
const { registerHooks } = vi.hoisted(() => ({
|
|
8
|
+
registerHooks: vi.fn(),
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("node:module", async (importOriginal) => {
|
|
12
|
+
const actual = await importOriginal<typeof import("node:module")>();
|
|
13
|
+
return { ...actual, registerHooks, register: vi.fn() };
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const CONTEXT = {} as ResolveHookContext;
|
|
17
|
+
|
|
18
|
+
function notFound(): never {
|
|
19
|
+
const err = new Error("not found") as Error & { code?: string };
|
|
20
|
+
err.code = "ERR_MODULE_NOT_FOUND";
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ok(url: string): ResolveFnOutput {
|
|
25
|
+
return { url, shortCircuit: true } as ResolveFnOutput;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("resolveSync (registered via module.registerHooks())", () => {
|
|
29
|
+
registerLoaderHook();
|
|
30
|
+
const resolveSync = registerHooks.mock.calls[0][0].resolve as (
|
|
31
|
+
specifier: string,
|
|
32
|
+
context: ResolveHookContext,
|
|
33
|
+
nextResolve: (specifier: string, context?: Partial<ResolveHookContext>) => ResolveFnOutput,
|
|
34
|
+
) => ResolveFnOutput;
|
|
35
|
+
|
|
36
|
+
it("registers exactly once via module.registerHooks()", () => {
|
|
37
|
+
expect(registerHooks).toHaveBeenCalledOnce();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("returns nextResolve's result directly when it succeeds", () => {
|
|
41
|
+
const nextResolve = vi.fn(() => ok("file:///real.js"));
|
|
42
|
+
expect(resolveSync("./real", CONTEXT, nextResolve)).toEqual(ok("file:///real.js"));
|
|
43
|
+
expect(nextResolve).toHaveBeenCalledOnce();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rethrows without retrying for a bare (non-relative) specifier", () => {
|
|
47
|
+
const nextResolve = vi.fn(() => notFound());
|
|
48
|
+
expect(() => resolveSync("some-package", CONTEXT, nextResolve)).toThrow("not found");
|
|
49
|
+
expect(nextResolve).toHaveBeenCalledOnce();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rethrows without retrying when the error has no code", () => {
|
|
53
|
+
const nextResolve = vi.fn(() => {
|
|
54
|
+
throw new Error("weird failure");
|
|
55
|
+
});
|
|
56
|
+
expect(() => resolveSync("./router", CONTEXT, nextResolve)).toThrow("weird failure");
|
|
57
|
+
expect(nextResolve).toHaveBeenCalledOnce();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rethrows without retrying for a non-retryable error code", () => {
|
|
61
|
+
const nextResolve = vi.fn(() => {
|
|
62
|
+
const err = new Error("permission denied") as Error & { code?: string };
|
|
63
|
+
err.code = "EACCES";
|
|
64
|
+
throw err;
|
|
65
|
+
});
|
|
66
|
+
expect(() => resolveSync("./router", CONTEXT, nextResolve)).toThrow("permission denied");
|
|
67
|
+
expect(nextResolve).toHaveBeenCalledOnce();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("retries with a .js suffix and returns it when that succeeds", () => {
|
|
71
|
+
const nextResolve = vi.fn((specifier: string) =>
|
|
72
|
+
specifier === "./router.js" ? ok("file:///router.js") : notFound(),
|
|
73
|
+
);
|
|
74
|
+
expect(resolveSync("./router", CONTEXT, nextResolve)).toEqual(ok("file:///router.js"));
|
|
75
|
+
expect(nextResolve).toHaveBeenCalledTimes(2);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("falls back to /index.js when the .js candidate also fails", () => {
|
|
79
|
+
const nextResolve = vi.fn((specifier: string) =>
|
|
80
|
+
specifier === "./router/index.js" ? ok("file:///router/index.js") : notFound(),
|
|
81
|
+
);
|
|
82
|
+
expect(resolveSync("./router", CONTEXT, nextResolve)).toEqual(ok("file:///router/index.js"));
|
|
83
|
+
expect(nextResolve).toHaveBeenCalledTimes(3);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("rethrows the original error when both retry candidates fail", () => {
|
|
87
|
+
const nextResolve = vi.fn(() => notFound());
|
|
88
|
+
expect(() => resolveSync("../router", CONTEXT, nextResolve)).toThrow("not found");
|
|
89
|
+
expect(nextResolve).toHaveBeenCalledTimes(3);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { RouteDefinition } from "@praxisjs/router";
|
|
4
|
+
|
|
5
|
+
import { flattenRoutes, isStaticPath, pathToOutputFile, resolvePaths } from "../routes";
|
|
6
|
+
|
|
7
|
+
const Noop = class {} as never;
|
|
8
|
+
|
|
9
|
+
describe("flattenRoutes", () => {
|
|
10
|
+
it("flattens a flat route list unchanged", () => {
|
|
11
|
+
const routes: RouteDefinition[] = [
|
|
12
|
+
{ path: "/", component: Noop },
|
|
13
|
+
{ path: "/about", component: Noop },
|
|
14
|
+
];
|
|
15
|
+
expect(flattenRoutes(routes).map((r) => r.fullPath)).toEqual(["/", "/about"]);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("prefixes nested children with the parent path", () => {
|
|
19
|
+
const routes: RouteDefinition[] = [
|
|
20
|
+
{
|
|
21
|
+
path: "/blog",
|
|
22
|
+
component: Noop,
|
|
23
|
+
children: [
|
|
24
|
+
{ path: "/", component: Noop },
|
|
25
|
+
{ path: "/:slug", component: Noop },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
expect(flattenRoutes(routes).map((r) => r.fullPath)).toEqual(["/blog", "/blog/", "/blog/:slug"]);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("treats a root parent's prefix as empty, matching RouterInstance.addRoute", () => {
|
|
33
|
+
const routes: RouteDefinition[] = [
|
|
34
|
+
{
|
|
35
|
+
path: "/",
|
|
36
|
+
component: Noop,
|
|
37
|
+
children: [{ path: "/dashboard", component: Noop }],
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
expect(flattenRoutes(routes).map((r) => r.fullPath)).toEqual(["/", "/dashboard"]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe("isStaticPath", () => {
|
|
45
|
+
it("treats plain paths as static", () => {
|
|
46
|
+
expect(isStaticPath("/about")).toBe(true);
|
|
47
|
+
expect(isStaticPath("/")).toBe(true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("treats :param segments as dynamic", () => {
|
|
51
|
+
expect(isStaticPath("/blog/:slug")).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("treats optional :param? segments as dynamic", () => {
|
|
55
|
+
expect(isStaticPath("/users/:id?")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("treats ** catch-alls as dynamic", () => {
|
|
59
|
+
expect(isStaticPath("/docs/**")).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("resolvePaths", () => {
|
|
64
|
+
it("returns static paths as-is", async () => {
|
|
65
|
+
const routes: RouteDefinition[] = [
|
|
66
|
+
{ path: "/", component: Noop },
|
|
67
|
+
{ path: "/about", component: Noop },
|
|
68
|
+
];
|
|
69
|
+
expect(await resolvePaths({ routes })).toEqual(["/", "/about"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("expands a dynamic route via getStaticPaths", async () => {
|
|
73
|
+
const routes: RouteDefinition[] = [{ path: "/blog/:slug", component: Noop }];
|
|
74
|
+
const paths = await resolvePaths({
|
|
75
|
+
routes,
|
|
76
|
+
getStaticPaths: async () => ["/blog/hello", "/blog/world"],
|
|
77
|
+
});
|
|
78
|
+
expect(paths).toEqual(["/blog/hello", "/blog/world"]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("skips dynamic routes with no getStaticPaths", async () => {
|
|
82
|
+
const routes: RouteDefinition[] = [
|
|
83
|
+
{ path: "/", component: Noop },
|
|
84
|
+
{ path: "/blog/:slug", component: Noop },
|
|
85
|
+
];
|
|
86
|
+
expect(await resolvePaths({ routes })).toEqual(["/"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("passes the route and its full path pattern to getStaticPaths", async () => {
|
|
90
|
+
const target: RouteDefinition = { path: "/blog/:slug", component: Noop };
|
|
91
|
+
const routes: RouteDefinition[] = [target];
|
|
92
|
+
let seen: [RouteDefinition, string] | undefined;
|
|
93
|
+
await resolvePaths({
|
|
94
|
+
routes,
|
|
95
|
+
getStaticPaths: async (route, fullPath) => {
|
|
96
|
+
seen = [route, fullPath];
|
|
97
|
+
return [];
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
expect(seen).toEqual([target, "/blog/:slug"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("de-duplicates paths across routes", async () => {
|
|
104
|
+
const routes: RouteDefinition[] = [
|
|
105
|
+
{ path: "/blog/:slug", component: Noop },
|
|
106
|
+
{ path: "/other/:slug", component: Noop },
|
|
107
|
+
];
|
|
108
|
+
const paths = await resolvePaths({
|
|
109
|
+
routes,
|
|
110
|
+
getStaticPaths: async () => ["/shared"],
|
|
111
|
+
});
|
|
112
|
+
expect(paths).toEqual(["/shared"]);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("expands a dynamic route via its own colocated getStaticPaths, with no global fallback needed", async () => {
|
|
116
|
+
const routes: RouteDefinition[] = [
|
|
117
|
+
{ path: "/blog/:slug", component: Noop, getStaticPaths: async () => ["/blog/hello", "/blog/world"] } as RouteDefinition,
|
|
118
|
+
];
|
|
119
|
+
expect(await resolvePaths({ routes })).toEqual(["/blog/hello", "/blog/world"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("passes the route's own full path to its colocated getStaticPaths", async () => {
|
|
123
|
+
let seen: string | undefined;
|
|
124
|
+
const routes: RouteDefinition[] = [
|
|
125
|
+
{
|
|
126
|
+
path: "/blog/:slug",
|
|
127
|
+
component: Noop,
|
|
128
|
+
getStaticPaths: async (fullPath: string) => {
|
|
129
|
+
seen = fullPath;
|
|
130
|
+
return [];
|
|
131
|
+
},
|
|
132
|
+
} as RouteDefinition,
|
|
133
|
+
];
|
|
134
|
+
await resolvePaths({ routes });
|
|
135
|
+
expect(seen).toBe("/blog/:slug");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("prefers a route's own getStaticPaths over the global fallback", async () => {
|
|
139
|
+
const routes: RouteDefinition[] = [
|
|
140
|
+
{ path: "/blog/:slug", component: Noop, getStaticPaths: async () => ["/blog/own"] } as RouteDefinition,
|
|
141
|
+
];
|
|
142
|
+
const paths = await resolvePaths({
|
|
143
|
+
routes,
|
|
144
|
+
getStaticPaths: async () => ["/blog/global"],
|
|
145
|
+
});
|
|
146
|
+
expect(paths).toEqual(["/blog/own"]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("falls back to the global getStaticPaths for routes with no colocated one, alongside routes that have their own", async () => {
|
|
150
|
+
const routes: RouteDefinition[] = [
|
|
151
|
+
{ path: "/blog/:slug", component: Noop, getStaticPaths: async () => ["/blog/own"] } as RouteDefinition,
|
|
152
|
+
{ path: "/other/:slug", component: Noop },
|
|
153
|
+
];
|
|
154
|
+
const paths = await resolvePaths({
|
|
155
|
+
routes,
|
|
156
|
+
getStaticPaths: async (_route, fullPath) => (fullPath === "/other/:slug" ? ["/other/global"] : []),
|
|
157
|
+
});
|
|
158
|
+
expect(paths).toEqual(["/blog/own", "/other/global"]);
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("pathToOutputFile", () => {
|
|
163
|
+
it("maps the root path to index.html", () => {
|
|
164
|
+
expect(pathToOutputFile("/")).toBe("index.html");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("maps a nested path to <path>/index.html", () => {
|
|
168
|
+
expect(pathToOutputFile("/about")).toBe("about/index.html");
|
|
169
|
+
expect(pathToOutputFile("/blog/hello-world")).toBe("blog/hello-world/index.html");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("normalizes surrounding slashes", () => {
|
|
173
|
+
expect(pathToOutputFile("/about/")).toBe("about/index.html");
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import * as nodeModule from "node:module";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { createServer, type Plugin, type ResolvedConfig } from "vite";
|
|
6
|
+
|
|
7
|
+
import type { RouterOptions } from "@praxisjs/router";
|
|
8
|
+
import type { ComponentConstructor } from "@praxisjs/shared/internal";
|
|
9
|
+
|
|
10
|
+
import type * as PrerenderModule from "./prerender";
|
|
11
|
+
import type { GetStaticPaths, RouteEntry } from "./routes";
|
|
12
|
+
import type { ResolveFnOutput, ResolveHookContext } from "node:module";
|
|
13
|
+
|
|
14
|
+
export type { GetStaticPaths, RouteEntry, RouteStaticPaths } from "./routes";
|
|
15
|
+
|
|
16
|
+
export interface SSGConfig {
|
|
17
|
+
/**
|
|
18
|
+
* Module specifier exporting the root component as its default export and
|
|
19
|
+
* the route table (same one passed to `@Router([...])`) as a named
|
|
20
|
+
* `routes` export, e.g. `"./src/app.tsx"`. Loaded through Vite's own SSR
|
|
21
|
+
* module graph — not imported directly by `vite.config.ts` — so the config
|
|
22
|
+
* file itself never needs to resolve JSX/component source.
|
|
23
|
+
*
|
|
24
|
+
* A dynamic route can expand itself into concrete paths by declaring its
|
|
25
|
+
* own `getStaticPaths` directly on the route object (see `RouteEntry`) —
|
|
26
|
+
* the recommended default, since it's colocated with the route and needs
|
|
27
|
+
* no path matching. `root` may *also* export a named `getStaticPaths` as a
|
|
28
|
+
* fallback for routes that don't declare their own; a route's own always
|
|
29
|
+
* takes precedence.
|
|
30
|
+
*
|
|
31
|
+
* Either way, this can only be reached through `root`, never passed as a
|
|
32
|
+
* plain config value here: Vite's own config loader bundles
|
|
33
|
+
* `vite.config.ts` with a minimal bundler that inlines relative imports
|
|
34
|
+
* (only bare package specifiers get externalized) but never lowers TC39
|
|
35
|
+
* decorators. A `getStaticPaths` written inline in `vite.config.ts` that
|
|
36
|
+
* reaches a decorated class through a relative import — e.g. one that
|
|
37
|
+
* loads a `@Collection`-decorated schema to enumerate content slugs —
|
|
38
|
+
* would bundle that raw decorator syntax straight into the config bundle,
|
|
39
|
+
* which Node cannot then execute. Exporting it from the same module as
|
|
40
|
+
* `root` (or from a route reachable from it) avoids this: it only ever
|
|
41
|
+
* loads through `ssrLoadModule`, which runs the app's real Vite pipeline
|
|
42
|
+
* (decorator lowering included).
|
|
43
|
+
*/
|
|
44
|
+
root: string;
|
|
45
|
+
routerOptions?: RouterOptions;
|
|
46
|
+
/** CSS selector for the mount container inside `index.html`. Default: `#app`. */
|
|
47
|
+
containerSelector?: string;
|
|
48
|
+
/**
|
|
49
|
+
* Enables client-side hydration (reconciling the prerendered DOM instead of
|
|
50
|
+
* discarding it). Default: `true`. Set to `false` to fall back to a plain
|
|
51
|
+
* client remount over the static HTML — no reconciliation runs at all,
|
|
52
|
+
* client or server, and `render()` in `@praxisjs/runtime` never sees the
|
|
53
|
+
* hydration marker.
|
|
54
|
+
*/
|
|
55
|
+
hydrate?: boolean;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── extensionless-relative-import retry hook ────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// Every other @praxisjs/* package is built with tsc, which emits relative
|
|
61
|
+
// specifiers exactly as written in source ("./router", not "./router.js") —
|
|
62
|
+
// harmless for every consumer so far, since they're all resolved through a
|
|
63
|
+
// bundler (Vite/esbuild/rollup), which resolves extensionless specifiers
|
|
64
|
+
// fine. Node's own ESM resolver does not: it's spec-strict and requires an
|
|
65
|
+
// explicit extension on relative specifiers, and @praxisjs/ssg is the first
|
|
66
|
+
// package here that needs to load that dependency chain (router, core, head,
|
|
67
|
+
// runtime) directly through Node, when `closeBundle` below dynamically
|
|
68
|
+
// imports ./prerender.
|
|
69
|
+
//
|
|
70
|
+
// This lives inline in the package's main entry, with zero relative imports
|
|
71
|
+
// of its own, rather than in a separate module: `registerLoaderHook()` must
|
|
72
|
+
// run *before* anything reachable from this file needs the workaround it
|
|
73
|
+
// installs, and a relative import here would hit the exact problem it's
|
|
74
|
+
// meant to fix before it ever gets the chance to run.
|
|
75
|
+
const RETRYABLE_CODES = new Set(["ERR_MODULE_NOT_FOUND", "ERR_UNSUPPORTED_DIR_IMPORT"]);
|
|
76
|
+
|
|
77
|
+
function resolveSync(
|
|
78
|
+
specifier: string,
|
|
79
|
+
context: ResolveHookContext,
|
|
80
|
+
nextResolve: (specifier: string, context?: Partial<ResolveHookContext>) => ResolveFnOutput,
|
|
81
|
+
): ResolveFnOutput {
|
|
82
|
+
try {
|
|
83
|
+
return nextResolve(specifier, context);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
const code = (err as { code?: string }).code;
|
|
86
|
+
if ((specifier.startsWith("./") || specifier.startsWith("../")) && code !== undefined && RETRYABLE_CODES.has(code)) {
|
|
87
|
+
for (const candidate of [`${specifier}.js`, `${specifier}/index.js`]) {
|
|
88
|
+
try {
|
|
89
|
+
return nextResolve(candidate, context);
|
|
90
|
+
} catch {
|
|
91
|
+
// try the next candidate
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let hookRegistered = false;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Installs the retry hook above for the rest of this process. Idempotent.
|
|
103
|
+
* `ssgPlugin()` calls this itself before dynamically importing
|
|
104
|
+
* `@praxisjs/ssg/prerender` — only needed directly if you're calling
|
|
105
|
+
* `prerender()` from your own Node script instead of going through
|
|
106
|
+
* `ssgPlugin()`, and must run before that import.
|
|
107
|
+
*
|
|
108
|
+
* Prefers `module.registerHooks()` (in-thread, sync, Node 22.15+) and falls
|
|
109
|
+
* back to the older `module.register()` (off-thread, needs its own module
|
|
110
|
+
* file — see loader-hook.ts, the asynchronous twin of `resolveSync` above)
|
|
111
|
+
* on earlier Node versions.
|
|
112
|
+
*/
|
|
113
|
+
export function registerLoaderHook(): void {
|
|
114
|
+
if (hookRegistered) return;
|
|
115
|
+
hookRegistered = true;
|
|
116
|
+
if (typeof nodeModule.registerHooks === "function") {
|
|
117
|
+
nodeModule.registerHooks({ resolve: resolveSync });
|
|
118
|
+
} else {
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-deprecated -- intentional fallback for Node < 22.15, which has no registerHooks
|
|
120
|
+
nodeModule.register(new URL("./loader-hook.js", import.meta.url));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── plugin ───────────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
interface RootModule {
|
|
127
|
+
default?: ComponentConstructor;
|
|
128
|
+
routes?: RouteEntry[];
|
|
129
|
+
getStaticPaths?: GetStaticPaths;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function loadEntry(
|
|
133
|
+
resolvedConfig: ResolvedConfig,
|
|
134
|
+
root: string,
|
|
135
|
+
withDomGlobals: typeof PrerenderModule.withDomGlobals,
|
|
136
|
+
): Promise<{
|
|
137
|
+
root: ComponentConstructor;
|
|
138
|
+
routes: RouteEntry[];
|
|
139
|
+
getStaticPaths: GetStaticPaths | undefined;
|
|
140
|
+
close: () => Promise<void>;
|
|
141
|
+
}> {
|
|
142
|
+
const server = await createServer({
|
|
143
|
+
configFile: resolvedConfig.configFile,
|
|
144
|
+
root: resolvedConfig.root,
|
|
145
|
+
server: { middlewareMode: true, watch: null },
|
|
146
|
+
appType: "custom",
|
|
147
|
+
// Every @praxisjs/* package (and anything else in node_modules) must
|
|
148
|
+
// resolve through plain Node `import()` here — the same path `prerender()`
|
|
149
|
+
// itself uses to load @praxisjs/core/runtime/router/head — instead of
|
|
150
|
+
// being re-processed through Vite's SSR transform. Two different copies
|
|
151
|
+
// of @praxisjs/core would mean two separate component-instance registries:
|
|
152
|
+
// mountComponent() (from the plain-Node copy prerender.ts imports) would
|
|
153
|
+
// construct instances that @Router([...])'s decorator (baked into the
|
|
154
|
+
// Vite-SSR-transformed copy the root module was loaded with) doesn't
|
|
155
|
+
// recognize as valid components.
|
|
156
|
+
ssr: { external: true },
|
|
157
|
+
// No client ever connects to this server and nothing here benefits from
|
|
158
|
+
// pre-bundling npm deps for browser delivery — and a background
|
|
159
|
+
// (re-)optimization pass restarting mid-request looked like a plausible
|
|
160
|
+
// cause of "transport was disconnected" errors on lazy-loaded routes.
|
|
161
|
+
optimizeDeps: { noDiscovery: true, include: [] },
|
|
162
|
+
});
|
|
163
|
+
try {
|
|
164
|
+
// @Router([...])'s class decorator calls createRouter() at
|
|
165
|
+
// module-evaluation time — ssrLoadModule() below runs that as a side
|
|
166
|
+
// effect of importing the root module, so it needs *a* window in place
|
|
167
|
+
// already, even though nothing is actually being rendered yet (the real,
|
|
168
|
+
// per-route window gets installed later, in prerender()).
|
|
169
|
+
const mod = await withDomGlobals("<!doctype html><html><head></head><body></body></html>", "http://localhost/", async () => {
|
|
170
|
+
return (await server.ssrLoadModule(root)) as RootModule;
|
|
171
|
+
});
|
|
172
|
+
if (!mod.default) {
|
|
173
|
+
throw new Error(`[@praxisjs/ssg] "${root}" has no default export — expected the root component.`);
|
|
174
|
+
}
|
|
175
|
+
if (!mod.routes) {
|
|
176
|
+
throw new Error(`[@praxisjs/ssg] "${root}" has no named "routes" export — expected the same route table passed to @Router([...]).`);
|
|
177
|
+
}
|
|
178
|
+
// Left open, closed by the caller after prerender() finishes: routes
|
|
179
|
+
// wrapped in Lazy(() => import(...)) keep their loader closures bound to
|
|
180
|
+
// *this* server's module runner for as long as they might still be
|
|
181
|
+
// invoked — closing it here would break lazy routes on every page after
|
|
182
|
+
// the first that needs them.
|
|
183
|
+
return { root: mod.default, routes: mod.routes, getStaticPaths: mod.getStaticPaths, close: () => server.close() };
|
|
184
|
+
} catch (err) {
|
|
185
|
+
await server.close();
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function ssgPlugin(config: SSGConfig): Plugin {
|
|
191
|
+
let resolvedConfig: ResolvedConfig;
|
|
192
|
+
|
|
193
|
+
// Before anything below ever dynamically imports ./prerender — see the
|
|
194
|
+
// retry-hook section above for why this needs to happen here.
|
|
195
|
+
registerLoaderHook();
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
name: "praxisjs:ssg",
|
|
199
|
+
apply: "build",
|
|
200
|
+
configResolved(c) {
|
|
201
|
+
resolvedConfig = c;
|
|
202
|
+
},
|
|
203
|
+
async closeBundle() {
|
|
204
|
+
const { prerender, withDomGlobals } = (await import("./prerender"));
|
|
205
|
+
|
|
206
|
+
const outDir = resolvedConfig.build.outDir;
|
|
207
|
+
const templatePath = join(resolvedConfig.root, outDir, "index.html");
|
|
208
|
+
const template = await readFile(templatePath, "utf-8");
|
|
209
|
+
const { root, routes, getStaticPaths, close } = await loadEntry(resolvedConfig, config.root, withDomGlobals);
|
|
210
|
+
|
|
211
|
+
let pages;
|
|
212
|
+
try {
|
|
213
|
+
pages = await prerender({
|
|
214
|
+
root,
|
|
215
|
+
routes,
|
|
216
|
+
routerOptions: config.routerOptions,
|
|
217
|
+
getStaticPaths,
|
|
218
|
+
template,
|
|
219
|
+
containerSelector: config.containerSelector,
|
|
220
|
+
hydrate: config.hydrate,
|
|
221
|
+
});
|
|
222
|
+
} finally {
|
|
223
|
+
await close();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const page of pages) {
|
|
227
|
+
const outFile = join(resolvedConfig.root, outDir, page.file);
|
|
228
|
+
await mkdir(dirname(outFile), { recursive: true });
|
|
229
|
+
await writeFile(outFile, page.html, "utf-8");
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ResolveFnOutput, ResolveHookContext } from "node:module";
|
|
2
|
+
|
|
3
|
+
// Loaded as a standalone hook module via module.register() (see register.ts)
|
|
4
|
+
// — only used as a fallback on Node versions without the newer, in-thread
|
|
5
|
+
// module.registerHooks() API, so it must have zero relative imports of its
|
|
6
|
+
// own: this file needs to work *before* the extensionless-import problem it
|
|
7
|
+
// exists to fix has been worked around. See register.ts for the full story.
|
|
8
|
+
//
|
|
9
|
+
// Every other @praxisjs/* package is built with tsc, which emits relative
|
|
10
|
+
// specifiers exactly as written in source ("./router", not "./router.js") —
|
|
11
|
+
// harmless for every consumer so far, since they're all resolved through a
|
|
12
|
+
// bundler (Vite/esbuild/rollup), which resolves extensionless specifiers
|
|
13
|
+
// fine. Node's own ESM resolver does not: it's spec-strict and requires an
|
|
14
|
+
// explicit extension on relative specifiers. This hook retries with ".js" /
|
|
15
|
+
// "/index.js" on the two failure modes that produces.
|
|
16
|
+
const RETRYABLE_CODES = new Set(["ERR_MODULE_NOT_FOUND", "ERR_UNSUPPORTED_DIR_IMPORT"]);
|
|
17
|
+
|
|
18
|
+
type NextResolve = (
|
|
19
|
+
specifier: string,
|
|
20
|
+
context?: Partial<ResolveHookContext>,
|
|
21
|
+
) => ResolveFnOutput | Promise<ResolveFnOutput>;
|
|
22
|
+
|
|
23
|
+
export async function resolve(
|
|
24
|
+
specifier: string,
|
|
25
|
+
context: ResolveHookContext,
|
|
26
|
+
nextResolve: NextResolve,
|
|
27
|
+
): Promise<ResolveFnOutput> {
|
|
28
|
+
try {
|
|
29
|
+
return await nextResolve(specifier, context);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
const code = (err as { code?: string }).code;
|
|
32
|
+
if ((specifier.startsWith("./") || specifier.startsWith("../")) && code !== undefined && RETRYABLE_CODES.has(code)) {
|
|
33
|
+
for (const candidate of [`${specifier}.js`, `${specifier}/index.js`]) {
|
|
34
|
+
try {
|
|
35
|
+
return await nextResolve(candidate, context);
|
|
36
|
+
} catch {
|
|
37
|
+
// try the next candidate
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
}
|