@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/LICENSE +21 -0
  3. package/dist/__tests__/loader-hook.test.d.ts +2 -0
  4. package/dist/__tests__/loader-hook.test.d.ts.map +1 -0
  5. package/dist/__tests__/loader-hook.test.js +66 -0
  6. package/dist/__tests__/loader-hook.test.js.map +1 -0
  7. package/dist/__tests__/plugin.test.d.ts +2 -0
  8. package/dist/__tests__/plugin.test.d.ts.map +1 -0
  9. package/dist/__tests__/plugin.test.js +128 -0
  10. package/dist/__tests__/plugin.test.js.map +1 -0
  11. package/dist/__tests__/prerender.test.d.ts +2 -0
  12. package/dist/__tests__/prerender.test.d.ts.map +1 -0
  13. package/dist/__tests__/prerender.test.js +211 -0
  14. package/dist/__tests__/prerender.test.js.map +1 -0
  15. package/dist/__tests__/register-fallback.test.d.ts +2 -0
  16. package/dist/__tests__/register-fallback.test.d.ts.map +1 -0
  17. package/dist/__tests__/register-fallback.test.js +20 -0
  18. package/dist/__tests__/register-fallback.test.js.map +1 -0
  19. package/dist/__tests__/resolve-sync.test.d.ts +2 -0
  20. package/dist/__tests__/resolve-sync.test.d.ts.map +1 -0
  21. package/dist/__tests__/resolve-sync.test.js +67 -0
  22. package/dist/__tests__/resolve-sync.test.js.map +1 -0
  23. package/dist/__tests__/routes.test.d.ts +2 -0
  24. package/dist/__tests__/routes.test.d.ts.map +1 -0
  25. package/dist/__tests__/routes.test.js +154 -0
  26. package/dist/__tests__/routes.test.js.map +1 -0
  27. package/dist/index.d.ts +59 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +156 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/loader-hook.d.ts +5 -0
  32. package/dist/loader-hook.d.ts.map +1 -0
  33. package/dist/loader-hook.js +34 -0
  34. package/dist/loader-hook.js.map +1 -0
  35. package/dist/prerender.d.ts +38 -0
  36. package/dist/prerender.d.ts.map +1 -0
  37. package/dist/prerender.js +116 -0
  38. package/dist/prerender.js.map +1 -0
  39. package/dist/routes.d.ts +32 -0
  40. package/dist/routes.d.ts.map +1 -0
  41. package/dist/routes.js +59 -0
  42. package/dist/routes.js.map +1 -0
  43. package/package.json +38 -0
  44. package/src/__tests__/loader-hook.test.ts +76 -0
  45. package/src/__tests__/plugin.test.ts +161 -0
  46. package/src/__tests__/prerender.test.ts +181 -0
  47. package/src/__tests__/register-fallback.test.ts +23 -0
  48. package/src/__tests__/resolve-sync.test.ts +91 -0
  49. package/src/__tests__/routes.test.ts +175 -0
  50. package/src/index.ts +233 -0
  51. package/src/loader-hook.ts +43 -0
  52. package/src/prerender.ts +168 -0
  53. package/src/routes.ts +83 -0
  54. 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
+ }