dh-remixer-sdk 0.0.23 → 0.0.24-10fa10f

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dh-remixer-sdk",
3
- "version": "0.0.23",
3
+ "version": "0.0.24-10fa10f",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "templates",
@@ -12,11 +12,18 @@
12
12
  "sdk-update": "./scripts/sdk-update/index.mjs"
13
13
  },
14
14
  "dependencies": {
15
- "chrome-launcher": "^1.2.1",
16
- "chrome-remote-interface": "^0.34.0",
17
- "estree-walker": "^3.0.3",
18
- "acorn": "^8.14.0",
19
- "acorn-jsx": "^5.3.2",
20
- "sucrase": "^3.35.0"
15
+ "acorn": "8.14.0",
16
+ "acorn-jsx": "5.3.2",
17
+ "chrome-launcher": "1.2.1",
18
+ "chrome-remote-interface": "0.34.0",
19
+ "estree-walker": "3.0.3",
20
+ "sucrase": "3.35.0",
21
+ "@sparticuz/chromium": "143.0.4"
22
+ },
23
+ "devDependencies": {
24
+ "eslint-plugin-n": "17.24.0",
25
+ "globals": "17.4.0",
26
+ "eslint": "9.0.0",
27
+ "eslint-plugin-import": "2.32.0"
21
28
  }
22
29
  }
@@ -2,18 +2,18 @@ import fs from "node:fs/promises";
2
2
  import path from "path";
3
3
  import { injectUnpackRedirectScript } from "./inject-unpack-redirect.mjs";
4
4
  import { buildRedirectHelmetHtml } from "./build-helmet-html.mjs";
5
- import { ROOT, WRITE_TARGET } from "./vars.mjs";
5
+ import { ROOT_ROUTE, REMIXER_BUILD_DIR } from "./vars.mjs";
6
6
 
7
7
  async function writeFile(route, content) {
8
- const productOut = path.join(WRITE_TARGET, route, "index.html");
8
+ const productOut = path.join(REMIXER_BUILD_DIR, route, "index.html");
9
9
  await fs.mkdir(path.dirname(productOut), { recursive: true });
10
10
  await fs.writeFile(productOut, content, "utf8");
11
11
  }
12
12
 
13
13
  export async function writeHelmetFile(html, currentRoute, isIndexable) {
14
14
  const idxTag = isIndexable ? "indexable" : "not_indexable";
15
- if (currentRoute === ROOT) {
16
- await injectUnpackRedirectScript("out");
15
+ if (currentRoute === ROOT_ROUTE) {
16
+ await injectUnpackRedirectScript();
17
17
  console.log(`[crawled][${idxTag}][edited] ${currentRoute}index.html`);
18
18
  return;
19
19
  }
@@ -12,11 +12,11 @@ import {
12
12
  import { buildSitemapXml } from "./build-sitemap-xml.mjs";
13
13
  import { writeHelmetFile } from "./fs-utils.mjs";
14
14
  import {
15
- ROOT,
15
+ ROOT_ROUTE,
16
16
  IS_INDEXABLE,
17
17
  IS_NOT_INDEXABLE,
18
18
  TARGET_DOMAIN,
19
- WRITE_TARGET,
19
+ REMIXER_BUILD_DIR,
20
20
  } from "./vars.mjs";
21
21
 
22
22
  /**
@@ -28,11 +28,42 @@ import {
28
28
 
29
29
  async function main() {
30
30
  const visistedRoutes = [];
31
- let queuedRoutes = [ROOT];
31
+ let queuedRoutes = [ROOT_ROUTE];
32
32
  const reactRoutes = await getRoutes();
33
- let sitemapRoutes = [ROOT, reactRoutes.find((i) => typeof i === "object")];
33
+ let sitemapRoutes = [ROOT_ROUTE, reactRoutes.find((i) => typeof i === "object")];
34
34
 
35
- const prerenderer = await Prerenderer.create();
35
+ // Retry logic with proper cleanup on failure
36
+ let prerenderer = null;
37
+ const maxRetries = 3;
38
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
39
+ try {
40
+ console.log(`[ssg-helmet] Creating Prerenderer (attempt ${attempt}/${maxRetries})...`);
41
+ prerenderer = await Prerenderer.create();
42
+ console.log(`[ssg-helmet] Prerenderer created successfully`);
43
+ break;
44
+ } catch (err) {
45
+ console.error(`[ssg-helmet] Prerenderer.create() failed on attempt ${attempt}:`, err.message);
46
+
47
+ // Clean up any partially-created prerenderer
48
+ if (prerenderer) {
49
+ try {
50
+ await prerenderer.close();
51
+ } catch (closeErr) {
52
+ console.error(`[ssg-helmet] Error during cleanup:`, closeErr.message);
53
+ }
54
+ prerenderer = null;
55
+ }
56
+
57
+ if (attempt === maxRetries) {
58
+ throw new Error(`Failed to create Prerenderer after ${maxRetries} attempts: ${err.message}`);
59
+ }
60
+
61
+ // Exponential backoff: 2s, 4s
62
+ const delay = attempt * 2000;
63
+ console.log(`[ssg-helmet] Retrying in ${delay}ms...`);
64
+ await new Promise(resolve => setTimeout(resolve, delay));
65
+ }
66
+ }
36
67
 
37
68
  while (queuedRoutes.length > 0) {
38
69
  const knownRoutes = [...queuedRoutes, ...visistedRoutes];
@@ -63,7 +94,7 @@ async function main() {
63
94
 
64
95
  const sitemapXml = buildSitemapXml(TARGET_DOMAIN, sitemapRoutes);
65
96
  await fs.writeFile(
66
- path.join(WRITE_TARGET, "sitemap.xml"),
97
+ path.join(REMIXER_BUILD_DIR, "sitemap.xml"),
67
98
  sitemapXml,
68
99
  "utf8",
69
100
  );
@@ -1,18 +1,9 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { UNPACK_SCRIPT_MARKER } from "./vars.mjs";
4
-
5
- /**
6
- * Reads out/index.html and injects an "unpack ?p=...&q=..." bootstrap script
7
- * that rewrites the URL back to the original SPA route BEFORE the app loads.
8
- *
9
- * - Idempotent: won't inject twice (uses a marker).
10
- * - Injects before </head> if present, otherwise before </body>, otherwise appends.
11
- *
12
- * @param {string} outDir - your build output directory (e.g. "out")
13
- */
14
- export async function injectUnpackRedirectScript(outDir) {
15
- const indexPath = path.join(outDir, "index.html");
3
+ import { REMIXER_BUILD_DIR, UNPACK_SCRIPT_MARKER } from "./vars.mjs";
4
+
5
+ export async function injectUnpackRedirectScript() {
6
+ const indexPath = path.join(REMIXER_BUILD_DIR, "index.html");
16
7
  let html = await fs.readFile(indexPath, "utf8");
17
8
 
18
9
  if (html.includes(UNPACK_SCRIPT_MARKER)) return; // already injected
@@ -2,9 +2,12 @@ import http from "node:http";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { URL } from "node:url";
5
+ import { setTimeout } from "node:timers/promises";
5
6
 
7
+ import { spawn } from "node:child_process";
6
8
  import * as chromeLauncher from "chrome-launcher";
7
9
  import CDP from "chrome-remote-interface";
10
+ import { REMIXER_BUILD_DIR } from "./vars.mjs";
8
11
 
9
12
  export class Prerenderer {
10
13
  static async create() {
@@ -13,69 +16,75 @@ export class Prerenderer {
13
16
  return instance;
14
17
  }
15
18
 
19
+ #renderQueue = Promise.resolve();
20
+
16
21
  async prerenderPage(route = "/") {
22
+ return (this.#renderQueue = this.#renderQueue.then(() =>
23
+ this.#renderPage(route),
24
+ ));
25
+ }
26
+
27
+ async #renderPage(route) {
17
28
  const r = this.#normalize(route);
29
+ const { Page, Runtime } = this.#client;
30
+
31
+ // Navigate
32
+ await Page.navigate({ url: `${this.#origin}${r}` });
33
+ await Page.loadEventFired();
34
+
35
+ // Wait for Helmet (non-fatal)
36
+ await Runtime.evaluate({
37
+ expression: `
38
+ (async function () {
39
+ const deadline = Date.now() + 15000;
40
+ while (Date.now() < deadline) {
41
+ if (document.head && document.head.querySelector('[data-react-helmet="true"]')) return true;
42
+ await new Promise(r => setTimeout(r, 50));
43
+ }
44
+ return false;
45
+ })()
46
+ `,
47
+ awaitPromise: true,
48
+ returnByValue: true,
49
+ });
18
50
 
19
- // Create a fresh tab (CDP target) for isolation + parallelism
20
- const target = await CDP.New({ port: this.#cdpPort });
21
- const client = await CDP({ port: this.#cdpPort, target });
51
+ await Runtime.evaluate({
52
+ expression: `new Promise(r => setTimeout(r, 100))`,
53
+ awaitPromise: true,
54
+ returnByValue: true,
55
+ });
22
56
 
23
- const { Page, Runtime } = client;
57
+ // Snapshot
58
+ const { result } = await Runtime.evaluate({
59
+ expression: "document.documentElement.outerHTML",
60
+ returnByValue: true,
61
+ });
24
62
 
25
- try {
26
- await Page.enable();
27
- await Runtime.enable();
28
-
29
- // Navigate
30
- await Page.navigate({ url: `${this.#origin}${r}` });
31
- await Page.loadEventFired(); // roughly equivalent to domcontentloaded+load
32
-
33
- // Wait for Helmet (non-fatal)
34
- await Runtime.evaluate({
35
- expression: `
36
- (async function () {
37
- const deadline = Date.now() + 15000;
38
- while (Date.now() < deadline) {
39
- if (document.head && document.head.querySelector('[data-react-helmet="true"]')) return true;
40
- await new Promise(r => setTimeout(r, 50));
41
- }
42
- return false;
43
- })()
44
- `,
45
- awaitPromise: true,
46
- returnByValue: true,
47
- });
48
-
49
- // Small settle (like your 100ms)
50
- await Runtime.evaluate({
51
- expression: `new Promise(r => setTimeout(r, 100))`,
52
- awaitPromise: true,
53
- returnByValue: true,
54
- });
55
-
56
- // Snapshot
57
- const { result } = await Runtime.evaluate({
58
- expression: "document.documentElement.outerHTML",
59
- returnByValue: true,
60
- });
61
-
62
- return result.value;
63
- } finally {
64
- await client.close().catch(() => {});
65
- await CDP.Close({ port: this.#cdpPort, id: target.id }).catch(() => {});
66
- }
63
+ return result.value;
67
64
  }
68
65
 
69
66
  async close() {
70
- // stop chromium
67
+ if (this.#client) {
68
+ await this.#client.close().catch(() => {});
69
+ this.#client = null;
70
+ }
71
+
71
72
  if (this.#chrome) {
72
73
  try {
73
- await this.#chrome.kill();
74
+ if (typeof this.#chrome.kill === "function") {
75
+ await this.#chrome.kill();
76
+ }
74
77
  } catch {}
75
78
  this.#chrome = null;
76
79
  }
77
80
 
78
- // stop server
81
+ if (this.#chromeProcess) {
82
+ try {
83
+ this.#chromeProcess.kill("SIGKILL");
84
+ } catch {}
85
+ this.#chromeProcess = null;
86
+ }
87
+
79
88
  if (this.#server) {
80
89
  await new Promise((res) => this.#server.close(res));
81
90
  this.#server = null;
@@ -85,8 +94,31 @@ export class Prerenderer {
85
94
  // ================= PRIVATE =================
86
95
 
87
96
  constructor() {
88
- this.#outDir = path.resolve("./out");
89
- this.#indexPath = path.join(this.#outDir, "index.html");
97
+ this.#indexPath = path.join(REMIXER_BUILD_DIR, "index.html");
98
+ }
99
+
100
+ async #verifyCDPReady() {
101
+ const maxAttempts = 20;
102
+ const delayMs = 500;
103
+
104
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
105
+ try {
106
+ await CDP.List({ port: this.#cdpPort });
107
+ if (attempt > 1) {
108
+ console.log(`[ssg-helmet] CDP ready after ${attempt} attempts`);
109
+ }
110
+ return;
111
+ } catch (error) {
112
+ console.log(`[ssg-helmet] CDP verification attempt ${attempt}/${maxAttempts} failed: ${error.message}`);
113
+ if (attempt === maxAttempts) {
114
+ throw new Error(
115
+ `Chrome CDP not ready after ${maxAttempts * delayMs}ms. ` +
116
+ `Chrome may have crashed. Error: ${error.message}`
117
+ );
118
+ }
119
+ await setTimeout(delayMs);
120
+ }
121
+ }
90
122
  }
91
123
 
92
124
  async #init() {
@@ -94,8 +126,97 @@ export class Prerenderer {
94
126
 
95
127
  await this.#startServer();
96
128
 
129
+ // In Lambda (CHROME_BIN set), spawn Chrome directly to capture stderr.
130
+ // Locally, use chrome-launcher for convenience.
131
+ if (process.env.CHROME_BIN) {
132
+ await this.#launchDirect();
133
+ } else {
134
+ await this.#launchWithChromeLauncher();
135
+ }
136
+
137
+ console.log(`[ssg-helmet] Chrome process launched on port ${this.#cdpPort}, verifying CDP readiness...`);
138
+ await this.#verifyCDPReady();
139
+ console.log(`[ssg-helmet] Chrome CDP ready`);
140
+
141
+ // Connect to the default tab
142
+ const targets = await CDP.List({ port: this.#cdpPort });
143
+ const pageTarget = targets.find((t) => t.type === "page");
144
+ if (!pageTarget) throw new Error("No default page target found in Chrome");
145
+
146
+ this.#client = await CDP({ port: this.#cdpPort, target: pageTarget });
147
+ await this.#client.Page.enable();
148
+ await this.#client.Runtime.enable();
149
+ console.log(`[ssg-helmet] Connected to default tab`);
150
+ }
151
+
152
+ async #launchDirect() {
153
+ const chromeBin = process.env.CHROME_BIN;
154
+ const debugPort = 9222 + Math.floor(Math.random() * 10000);
155
+
156
+ // Log Chrome version first
157
+ try {
158
+ const ver = spawn(chromeBin, ["--version"], { stdio: ["ignore", "pipe", "pipe"] });
159
+ let out = "";
160
+ ver.stdout.on("data", (d) => (out += d));
161
+ await new Promise((res) => ver.on("close", res));
162
+ console.log(`[ssg-helmet] Chrome version: ${out.trim()}`);
163
+ } catch {}
164
+
165
+ const defaultFlags = [
166
+ "--headless",
167
+ "--no-sandbox",
168
+ "--disable-setuid-sandbox",
169
+ "--disable-gpu",
170
+ "--disable-dev-shm-usage",
171
+ "--no-zygote",
172
+ "--disable-background-networking",
173
+ "--disable-extensions",
174
+ "--disable-background-timer-throttling",
175
+ "--disable-backgrounding-occluded-windows",
176
+ "--disable-renderer-backgrounding",
177
+ ];
178
+
179
+ const chromeFlags = process.env.CHROME_FLAGS
180
+ ? ["--headless=new", ...process.env.CHROME_FLAGS.split(",").map((f) => f.trim())]
181
+ : defaultFlags;
182
+
183
+ const allArgs = [
184
+ ...chromeFlags,
185
+ "--user-data-dir=/tmp/chrome-data",
186
+ `--remote-debugging-port=${debugPort}`,
187
+ "--remote-debugging-address=127.0.0.1",
188
+ "about:blank",
189
+ ];
190
+
191
+ console.log(`[ssg-helmet] Spawning ${chromeBin} on port ${debugPort}`);
192
+ console.log(`[ssg-helmet] Args: ${allArgs.join(" ")}`);
193
+
194
+ this.#chromeProcess = spawn(chromeBin, allArgs, {
195
+ stdio: ["ignore", "pipe", "pipe"],
196
+ env: {
197
+ ...process.env,
198
+ HOME: "/tmp",
199
+ XDG_CACHE_HOME: "/tmp/.cache",
200
+ XDG_CONFIG_HOME: "/tmp/.config",
201
+ FONTCONFIG_CACHE: "/tmp/fontconfig-cache",
202
+ },
203
+ });
204
+
205
+ let stderrBuf = "";
206
+ this.#chromeProcess.stderr.on("data", (d) => {
207
+ stderrBuf += d.toString();
208
+ });
209
+ this.#chromeProcess.on("exit", (code, signal) => {
210
+ console.log(`[ssg-helmet] Chrome exited: code=${code}, signal=${signal}`);
211
+ if (stderrBuf) console.log(`[ssg-helmet] Chrome stderr:\n${stderrBuf.slice(0, 4000)}`);
212
+ });
213
+
214
+ this.#cdpPort = debugPort;
215
+ }
216
+
217
+ async #launchWithChromeLauncher() {
97
218
  const defaultFlags = [
98
- "--headless=new",
219
+ "--headless",
99
220
  "--no-sandbox",
100
221
  "--disable-gpu",
101
222
  "--disable-dev-shm-usage",
@@ -104,9 +225,11 @@ export class Prerenderer {
104
225
  ];
105
226
 
106
227
  const chromeFlags = process.env.CHROME_FLAGS
107
- ? ["--headless=new", ...process.env.CHROME_FLAGS.split(",").map(f => f.trim())]
228
+ ? ["--headless", ...process.env.CHROME_FLAGS.split(",").map((f) => f.trim())]
108
229
  : defaultFlags;
109
230
 
231
+ console.log(`[ssg-helmet] Launching Chrome with flags:`, chromeFlags);
232
+
110
233
  this.#chrome = await chromeLauncher.launch({
111
234
  chromePath: process.env.CHROME_BIN || undefined,
112
235
  chromeFlags,
@@ -133,7 +256,7 @@ export class Prerenderer {
133
256
  return res.end("Bad Request");
134
257
  }
135
258
 
136
- const filePath = path.join(this.#outDir, safe);
259
+ const filePath = path.join(REMIXER_BUILD_DIR, safe);
137
260
 
138
261
  try {
139
262
  const stat = await fs.stat(filePath);
@@ -183,9 +306,10 @@ export class Prerenderer {
183
306
 
184
307
  #server;
185
308
  #origin;
186
- #outDir;
187
309
  #indexPath;
188
310
 
311
+ #client;
189
312
  #chrome;
313
+ #chromeProcess;
190
314
  #cdpPort;
191
315
  }
@@ -1,8 +1,13 @@
1
- export const OUT = path.resolve("./out");
2
- export const WRITE_TARGET = path.resolve("./");
3
- export const IS_INDEXABLE = true;
4
- export const IS_NOT_INDEXABLE = false;
5
- export const ROOT = "/";
1
+ import path from "node:path";
2
+
3
+ export const REMIXER_BUILD_DIR =
4
+ process.env.REMIXER_BUILD_DIR || path.resolve("./out");
5
+ export const REMIXER_SOURCE_DIR =
6
+ process.env.REMIXER_SOURCE_DIR || path.resolve("./");
6
7
  export const TARGET_DOMAIN =
7
8
  process.env.NEXT_PUBLIC_DOMAIN || "https://example.com";
9
+
10
+ export const ROOT_ROUTE = "/";
11
+ export const IS_INDEXABLE = true;
12
+ export const IS_NOT_INDEXABLE = false;
8
13
  export const UNPACK_SCRIPT_MARKER = "redirect-helmet-unpack-p-v1";
@@ -26,12 +26,6 @@
26
26
  "@types/node": "22.19.1",
27
27
  "@vitejs/plugin-react": "5.1.1",
28
28
  "typescript": "5.8.3",
29
- "vite": "6.4.1",
30
- "chrome-launcher": "1.2.1",
31
- "chrome-remote-interface": "0.34.0",
32
- "acorn": "^8.16.0",
33
- "acorn-jsx": "^5.3.2",
34
- "sucrase": "^3.35.1",
35
- "estree-walker": "^3.0.3"
29
+ "vite": "6.4.1"
36
30
  }
37
31
  }