tiendu 0.3.1 → 0.5.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.
@@ -0,0 +1,350 @@
1
+ import { createServer } from "node:http";
2
+ import { Readable } from "node:stream";
3
+
4
+ const DEFAULT_PORT = 9292;
5
+ const MAX_PORT_ATTEMPTS = 20;
6
+ const MAX_SSE_CLIENTS = 20;
7
+ const RELOAD_DEBOUNCE_MS = 150;
8
+ const HEARTBEAT_INTERVAL_MS = 15_000;
9
+ const PROXY_TIMEOUT_MS = 30_000;
10
+ const MAX_PROXY_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
11
+
12
+ const LIVE_RELOAD_PATH = "/__tiendu__/livereload.js";
13
+ const EVENTS_PATH = "/__tiendu__/events";
14
+
15
+ const LIVE_RELOAD_SCRIPT = `const source = new EventSource(${JSON.stringify(EVENTS_PATH)});
16
+ let reloadTimer = null;
17
+
18
+ source.addEventListener("reload", () => {
19
+ if (reloadTimer) clearTimeout(reloadTimer);
20
+ reloadTimer = setTimeout(() => window.location.reload(), 60);
21
+ });
22
+ `;
23
+
24
+ const HOP_BY_HOP_HEADERS = new Set([
25
+ "connection",
26
+ "keep-alive",
27
+ "proxy-authenticate",
28
+ "proxy-authorization",
29
+ "te",
30
+ "trailer",
31
+ "transfer-encoding",
32
+ "upgrade",
33
+ ]);
34
+
35
+ const readRequestBody = async (request) => {
36
+ const chunks = [];
37
+ let totalBytes = 0;
38
+
39
+ for await (const chunk of request) {
40
+ totalBytes += chunk.length;
41
+ if (totalBytes > MAX_PROXY_REQUEST_BODY_BYTES) {
42
+ const error = new Error("Local preview request body is too large.");
43
+ error.statusCode = 413;
44
+ throw error;
45
+ }
46
+
47
+ chunks.push(chunk);
48
+ }
49
+
50
+ if (chunks.length === 0) return undefined;
51
+ return Buffer.concat(chunks);
52
+ };
53
+
54
+ const createForwardHeaders = (request, previewHostname) => {
55
+ const headers = new Headers();
56
+
57
+ for (const [name, value] of Object.entries(request.headers)) {
58
+ if (value == null) continue;
59
+
60
+ const normalizedName = name.toLowerCase();
61
+ if (
62
+ HOP_BY_HOP_HEADERS.has(normalizedName) ||
63
+ normalizedName === "host" ||
64
+ normalizedName === "origin" ||
65
+ normalizedName === "referer" ||
66
+ normalizedName === "content-length"
67
+ ) {
68
+ continue;
69
+ }
70
+
71
+ if (Array.isArray(value)) {
72
+ for (const entry of value) {
73
+ headers.append(name, entry);
74
+ }
75
+ continue;
76
+ }
77
+
78
+ headers.set(name, value);
79
+ }
80
+
81
+ headers.set("host", previewHostname);
82
+ headers.set("x-forwarded-host", previewHostname);
83
+
84
+ return headers;
85
+ };
86
+
87
+ const isHtmlResponse = (headers) =>
88
+ (headers.get("content-type") ?? "").toLowerCase().includes("text/html");
89
+
90
+ const isHtmlDocument = (html) => /<html\b|<!doctype\s+html/i.test(html);
91
+
92
+ const injectLiveReloadScript = (html) => {
93
+ if (html.includes(LIVE_RELOAD_PATH)) return html;
94
+ if (!isHtmlDocument(html)) return html;
95
+
96
+ const scriptTag = `<script type="module" src="${LIVE_RELOAD_PATH}"></script>`;
97
+
98
+ if (html.includes("</head>")) {
99
+ return html.replace("</head>", `${scriptTag}</head>`);
100
+ }
101
+
102
+ if (html.includes("</body>")) {
103
+ return html.replace("</body>", `${scriptTag}</body>`);
104
+ }
105
+
106
+ return `${html}${scriptTag}`;
107
+ };
108
+
109
+ const rewriteSetCookie = (cookieValue) =>
110
+ cookieValue
111
+ .replace(/;\s*Secure/gi, "")
112
+ .replace(/;\s*Domain=[^;]+/gi, "");
113
+
114
+ const rewriteLocationHeader = (locationValue, localOrigin, previewOrigin, upstreamOrigin) => {
115
+ if (!locationValue) return null;
116
+
117
+ try {
118
+ const locationUrl = new URL(locationValue, previewOrigin);
119
+ if (
120
+ locationUrl.origin === previewOrigin.origin ||
121
+ locationUrl.origin === upstreamOrigin.origin
122
+ ) {
123
+ return `${localOrigin.origin}${locationUrl.pathname}${locationUrl.search}${locationUrl.hash}`;
124
+ }
125
+
126
+ return locationValue;
127
+ } catch {
128
+ return locationValue;
129
+ }
130
+ };
131
+
132
+ const writeResponseHeaders = (response, serverResponse, context) => {
133
+ const { localOrigin, previewOrigin, upstreamOrigin } = context;
134
+
135
+ for (const [name, value] of response.headers) {
136
+ const normalizedName = name.toLowerCase();
137
+ if (
138
+ HOP_BY_HOP_HEADERS.has(normalizedName) ||
139
+ normalizedName === "content-length" ||
140
+ normalizedName === "content-encoding" ||
141
+ normalizedName === "set-cookie"
142
+ ) {
143
+ continue;
144
+ }
145
+
146
+ if (normalizedName === "location") {
147
+ const rewritten = rewriteLocationHeader(
148
+ value,
149
+ localOrigin,
150
+ previewOrigin,
151
+ upstreamOrigin,
152
+ );
153
+ if (rewritten) serverResponse.setHeader(name, rewritten);
154
+ continue;
155
+ }
156
+
157
+ serverResponse.setHeader(name, value);
158
+ }
159
+
160
+ const setCookies = response.headers.getSetCookie?.() ?? [];
161
+ if (setCookies.length > 0) {
162
+ serverResponse.setHeader(
163
+ "set-cookie",
164
+ setCookies.map(rewriteSetCookie),
165
+ );
166
+ }
167
+ };
168
+
169
+ const listenOnAvailablePort = (server, preferredPort) =>
170
+ new Promise((resolve, reject) => {
171
+ let currentPort = preferredPort;
172
+
173
+ const tryListen = () => {
174
+ const onError = (error) => {
175
+ server.off("listening", onListening);
176
+
177
+ if (error?.code === "EADDRINUSE" && currentPort < preferredPort + MAX_PORT_ATTEMPTS) {
178
+ currentPort += 1;
179
+ tryListen();
180
+ return;
181
+ }
182
+
183
+ reject(error);
184
+ };
185
+
186
+ const onListening = () => {
187
+ server.off("error", onError);
188
+ const address = server.address();
189
+ if (!address || typeof address === "string") {
190
+ reject(new Error("Could not determine local preview port."));
191
+ return;
192
+ }
193
+
194
+ resolve(address.port);
195
+ };
196
+
197
+ server.once("error", onError);
198
+ server.once("listening", onListening);
199
+ server.listen(currentPort, "localhost");
200
+ };
201
+
202
+ tryListen();
203
+ });
204
+
205
+ export const startLocalPreviewServer = async ({
206
+ apiBaseUrl,
207
+ previewHostname,
208
+ port = DEFAULT_PORT,
209
+ }) => {
210
+ const upstreamOrigin = new URL(apiBaseUrl);
211
+ const previewOrigin = new URL(`${upstreamOrigin.protocol}//${previewHostname}`);
212
+ const sseClients = new Set();
213
+ let reloadTimer = null;
214
+
215
+ const server = createServer(async (request, response) => {
216
+ if (!request.url) {
217
+ response.writeHead(400);
218
+ response.end("Missing request URL");
219
+ return;
220
+ }
221
+
222
+ const localOrigin = new URL(`http://${request.headers.host ?? `127.0.0.1:${port}`}`);
223
+ const requestUrl = new URL(request.url, localOrigin);
224
+
225
+ if (requestUrl.pathname === LIVE_RELOAD_PATH) {
226
+ response.writeHead(200, {
227
+ "content-type": "application/javascript; charset=utf-8",
228
+ "cache-control": "no-store",
229
+ });
230
+ response.end(LIVE_RELOAD_SCRIPT);
231
+ return;
232
+ }
233
+
234
+ if (requestUrl.pathname === EVENTS_PATH) {
235
+ response.writeHead(200, {
236
+ "content-type": "text/event-stream; charset=utf-8",
237
+ "cache-control": "no-store",
238
+ connection: "keep-alive",
239
+ });
240
+ response.write("event: connected\ndata: ok\n\n");
241
+
242
+ if (sseClients.size >= MAX_SSE_CLIENTS) {
243
+ const oldestClient = sseClients.values().next().value;
244
+ oldestClient?.end();
245
+ if (oldestClient) {
246
+ sseClients.delete(oldestClient);
247
+ }
248
+ }
249
+
250
+ sseClients.add(response);
251
+
252
+ request.on("close", () => {
253
+ sseClients.delete(response);
254
+ });
255
+ return;
256
+ }
257
+
258
+ const targetUrl = new URL(requestUrl.pathname + requestUrl.search, upstreamOrigin);
259
+
260
+ try {
261
+ const body = await readRequestBody(request);
262
+ const upstreamResponse = await fetch(targetUrl, {
263
+ method: request.method,
264
+ headers: createForwardHeaders(request, previewHostname),
265
+ body,
266
+ redirect: "manual",
267
+ signal: AbortSignal.timeout(PROXY_TIMEOUT_MS),
268
+ });
269
+
270
+ if (isHtmlResponse(upstreamResponse.headers)) {
271
+ const html = injectLiveReloadScript(await upstreamResponse.text());
272
+ writeResponseHeaders(upstreamResponse, response, {
273
+ localOrigin,
274
+ previewOrigin,
275
+ upstreamOrigin,
276
+ });
277
+ response.statusCode = upstreamResponse.status;
278
+ response.setHeader("cache-control", "no-store");
279
+ response.setHeader("content-length", Buffer.byteLength(html, "utf-8"));
280
+ response.end(html);
281
+ return;
282
+ }
283
+
284
+ writeResponseHeaders(upstreamResponse, response, {
285
+ localOrigin,
286
+ previewOrigin,
287
+ upstreamOrigin,
288
+ });
289
+ response.statusCode = upstreamResponse.status;
290
+
291
+ if (!upstreamResponse.body) {
292
+ response.end();
293
+ return;
294
+ }
295
+
296
+ const proxyStream = Readable.fromWeb(upstreamResponse.body);
297
+ proxyStream.on("error", (error) => {
298
+ console.warn(`Local preview proxy stream error: ${error.message}`);
299
+ response.destroy(error);
300
+ });
301
+ proxyStream.pipe(response);
302
+ } catch (error) {
303
+ const statusCode = error.statusCode ?? 502;
304
+ response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8" });
305
+ response.end(`Local preview proxy error: ${error.message}`);
306
+ }
307
+ });
308
+
309
+ const heartbeat = setInterval(() => {
310
+ for (const client of sseClients) {
311
+ client.write(": ping\n\n");
312
+ }
313
+ }, HEARTBEAT_INTERVAL_MS);
314
+
315
+ const boundPort = await listenOnAvailablePort(server, port);
316
+
317
+ return {
318
+ url: `http://localhost:${boundPort}/`,
319
+ notifyReload() {
320
+ if (reloadTimer) clearTimeout(reloadTimer);
321
+
322
+ reloadTimer = setTimeout(() => {
323
+ reloadTimer = null;
324
+ for (const client of sseClients) {
325
+ client.write("event: reload\ndata: now\n\n");
326
+ }
327
+ }, RELOAD_DEBOUNCE_MS);
328
+ },
329
+ async close() {
330
+ if (reloadTimer) clearTimeout(reloadTimer);
331
+ clearInterval(heartbeat);
332
+
333
+ for (const client of sseClients) {
334
+ client.end();
335
+ }
336
+ sseClients.clear();
337
+
338
+ await new Promise((resolve, reject) => {
339
+ server.close((error) => {
340
+ if (error) {
341
+ reject(error);
342
+ return;
343
+ }
344
+
345
+ resolve();
346
+ });
347
+ });
348
+ },
349
+ };
350
+ };
@@ -0,0 +1,166 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { fileExists } from "./fs-utils.mjs";
6
+ import { rewriteCssAssetUrls } from "./assets.mjs";
7
+
8
+ const POSTCSS_CONFIG_FILES = [
9
+ "postcss.config.mjs",
10
+ "postcss.config.js",
11
+ "postcss.config.cjs",
12
+ "postcss.config.json",
13
+ ];
14
+
15
+ const getProjectRequire = (rootDir) =>
16
+ createRequire(path.join(rootDir, "package.json"));
17
+
18
+ const unwrapModule = (moduleNamespace) => moduleNamespace?.default ?? moduleNamespace;
19
+
20
+ const importProjectModule = async (rootDir, specifier) => {
21
+ try {
22
+ const requireFromProject = getProjectRequire(rootDir);
23
+ const resolvedPath = requireFromProject.resolve(specifier);
24
+ return await import(pathToFileURL(resolvedPath).href);
25
+ } catch {
26
+ return null;
27
+ }
28
+ };
29
+
30
+ const instantiatePlugin = async (rootDir, pluginEntry, pluginOptions) => {
31
+ if (!pluginEntry) return null;
32
+
33
+ if (typeof pluginEntry === "string") {
34
+ const moduleNamespace = await importProjectModule(rootDir, pluginEntry);
35
+ if (!moduleNamespace) {
36
+ throw new Error(
37
+ `Could not resolve PostCSS plugin "${pluginEntry}" from the theme project.`,
38
+ );
39
+ }
40
+
41
+ const pluginFactory = unwrapModule(moduleNamespace);
42
+ if (typeof pluginFactory === "function") {
43
+ return pluginOptions === undefined || pluginOptions === true
44
+ ? pluginFactory()
45
+ : pluginFactory(pluginOptions);
46
+ }
47
+
48
+ return pluginFactory;
49
+ }
50
+
51
+ if (Array.isArray(pluginEntry)) {
52
+ const [nestedEntry, nestedOptions] = pluginEntry;
53
+ return instantiatePlugin(rootDir, nestedEntry, nestedOptions);
54
+ }
55
+
56
+ if (typeof pluginEntry === "function") {
57
+ return pluginOptions === undefined
58
+ ? pluginEntry
59
+ : pluginEntry(pluginOptions);
60
+ }
61
+
62
+ return pluginEntry;
63
+ };
64
+
65
+ const normalizePlugins = async (rootDir, plugins) => {
66
+ if (!plugins) return [];
67
+
68
+ if (Array.isArray(plugins)) {
69
+ const resolvedPlugins = [];
70
+
71
+ for (const pluginEntry of plugins) {
72
+ const plugin = await instantiatePlugin(rootDir, pluginEntry);
73
+ if (plugin) resolvedPlugins.push(plugin);
74
+ }
75
+
76
+ return resolvedPlugins;
77
+ }
78
+
79
+ if (typeof plugins === "object") {
80
+ const resolvedPlugins = [];
81
+
82
+ for (const [pluginName, pluginOptions] of Object.entries(plugins)) {
83
+ if (!pluginOptions) continue;
84
+ const plugin = await instantiatePlugin(rootDir, pluginName, pluginOptions);
85
+ if (plugin) resolvedPlugins.push(plugin);
86
+ }
87
+
88
+ return resolvedPlugins;
89
+ }
90
+
91
+ return [];
92
+ };
93
+
94
+ const loadPostcssConfig = async (rootDir) => {
95
+ for (const configFile of POSTCSS_CONFIG_FILES) {
96
+ const configPath = path.join(rootDir, configFile);
97
+ if (!(await fileExists(configPath))) continue;
98
+
99
+ if (configFile.endsWith(".json")) {
100
+ const raw = await readFile(configPath, "utf-8");
101
+ return JSON.parse(raw);
102
+ }
103
+
104
+ const moduleNamespace = await import(pathToFileURL(configPath).href);
105
+ let config = unwrapModule(moduleNamespace);
106
+
107
+ if (typeof config === "function") {
108
+ config = await config({ env: process.env.NODE_ENV ?? "development" });
109
+ }
110
+
111
+ return config ?? null;
112
+ }
113
+
114
+ return null;
115
+ };
116
+
117
+ export const createCssPostCssPlugin = async (rootDir, { sourceDirs } = {}) => {
118
+ const postcssModule = await importProjectModule(rootDir, "postcss");
119
+ const postcss = postcssModule ? unwrapModule(postcssModule) : null;
120
+ const config = postcssModule ? await loadPostcssConfig(rootDir) : null;
121
+
122
+ let plugins = [];
123
+ if (config?.plugins) {
124
+ plugins = await normalizePlugins(rootDir, config.plugins);
125
+ } else if (postcssModule) {
126
+ const tailwindModule = await importProjectModule(rootDir, "@tailwindcss/postcss");
127
+ if (tailwindModule) {
128
+ const tailwindPluginFactory = unwrapModule(tailwindModule);
129
+ plugins = [
130
+ typeof tailwindPluginFactory === "function"
131
+ ? tailwindPluginFactory()
132
+ : tailwindPluginFactory,
133
+ ];
134
+ }
135
+ }
136
+
137
+ return {
138
+ name: "tiendu-postcss",
139
+ setup(build) {
140
+ build.onLoad({ filter: /\.css$/ }, async (args) => {
141
+ const source = await readFile(args.path, "utf-8");
142
+ const rewrittenSource = await rewriteCssAssetUrls(source, args.path, rootDir, sourceDirs);
143
+
144
+ if (!postcss || plugins.length === 0) {
145
+ return {
146
+ contents: rewrittenSource,
147
+ loader: "css",
148
+ resolveDir: path.dirname(args.path),
149
+ watchFiles: [args.path],
150
+ };
151
+ }
152
+
153
+ const result = await postcss(plugins).process(rewrittenSource, {
154
+ from: args.path,
155
+ });
156
+
157
+ return {
158
+ contents: result.css,
159
+ loader: "css",
160
+ resolveDir: path.dirname(args.path),
161
+ watchFiles: [args.path],
162
+ };
163
+ });
164
+ },
165
+ };
166
+ };
package/lib/preview.mjs CHANGED
@@ -2,7 +2,7 @@ import * as p from "@clack/prompts";
2
2
  import { loadConfigOrFail, writeConfig } from "./config.mjs";
3
3
  import { apiFetch } from "./api.mjs";
4
4
 
5
- const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
5
+ export const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
6
6
  const base = new URL(apiBaseUrl);
7
7
  const hasExplicitPort = previewHostname.includes(":");
8
8
  return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
@@ -367,12 +367,22 @@ export const previewOpen = async () => {
367
367
  const url = buildPreviewUrl(config.apiBaseUrl, preview.previewHostname);
368
368
  spinner.stop(`Opening ${url}`);
369
369
 
370
- const { exec } = await import("node:child_process");
371
- const cmd =
372
- process.platform === "darwin"
373
- ? "open"
374
- : process.platform === "win32"
375
- ? "start"
376
- : "xdg-open";
377
- exec(`${cmd} ${url}`);
370
+ const { spawn } = await import("node:child_process");
371
+
372
+ if (process.platform === "win32") {
373
+ const child = spawn("cmd", ["/c", "start", "", url], {
374
+ detached: true,
375
+ stdio: "ignore",
376
+ windowsHide: true,
377
+ });
378
+ child.unref();
379
+ return;
380
+ }
381
+
382
+ const cmd = process.platform === "darwin" ? "open" : "xdg-open";
383
+ const child = spawn(cmd, [url], {
384
+ detached: true,
385
+ stdio: "ignore",
386
+ });
387
+ child.unref();
378
388
  };
package/lib/publish.mjs CHANGED
@@ -1,8 +1,9 @@
1
1
  import * as p from "@clack/prompts";
2
- import { loadConfigOrFail, writeConfig } from "./config.mjs";
2
+ import { loadConfigOrFail, writeConfig, isBuiltTheme } from "./config.mjs";
3
3
  import { publishPreview } from "./preview.mjs";
4
+ import { push } from "./push.mjs";
4
5
 
5
- export const publish = async () => {
6
+ export const publish = async ({ skipBuild = false } = {}) => {
6
7
  const { config, credentials } = await loadConfigOrFail();
7
8
 
8
9
  if (!config.previewKey) {
@@ -19,6 +20,15 @@ export const publish = async () => {
19
20
  process.exit(0);
20
21
  }
21
22
 
23
+ if (await isBuiltTheme()) {
24
+ p.log.info(
25
+ skipBuild
26
+ ? "Syncing existing dist/ output to the preview before publishing..."
27
+ : "Building and syncing the latest dist/ output before publishing...",
28
+ );
29
+ await push({ skipBuild });
30
+ }
31
+
22
32
  const spinner = p.spinner();
23
33
  spinner.start("Publishing preview...");
24
34