tiendu 0.4.0 → 0.6.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/lib/dev.mjs CHANGED
@@ -1,58 +1,120 @@
1
1
  import { watch } from "node:fs";
2
- import { readFile, readdir, stat } from "node:fs/promises";
2
+ import { readFile, stat } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import * as p from "@clack/prompts";
5
- import { zipSync } from "fflate";
6
- import { loadConfigOrFail, writeConfig, isBuiltTheme, getDistDir } from "./config.mjs";
5
+ import { loadConfigOrFail, isBuiltTheme, getDistDir } from "./config.mjs";
7
6
  import {
8
- createPreview,
9
- listPreviews,
10
- resolveActivePreview,
7
+ fetchPreviewDetails,
8
+ resolvePreviewKeyInteractively,
11
9
  } from "./preview.mjs";
12
10
  import {
13
11
  deletePreviewFile,
14
12
  uploadPreviewFileMultipart,
15
- uploadPreviewZip,
16
13
  } from "./api.mjs";
17
14
  import { build } from "./build.mjs";
15
+ import { isDotfile } from "./fs-utils.mjs";
16
+ import { startLocalPreviewServer } from "./local-preview.mjs";
17
+ import { pushPreparedDirectoryToPreview } from "./push.mjs";
18
+ import { retryAsync } from "./retry.mjs";
18
19
 
19
- const isDotfile = (name) => name.startsWith(".");
20
-
21
- const buildPreviewUrl = (apiBaseUrl, previewHostname) => {
22
- const base = new URL(apiBaseUrl);
23
- const hasExplicitPort = previewHostname.includes(":");
24
- return `${base.protocol}//${previewHostname}${!hasExplicitPort && base.port ? `:${base.port}` : ""}/`;
25
- };
20
+ const RETRY_ATTEMPTS = 3;
21
+ const MAX_SYNC_FILE_SIZE_BYTES = 20 * 1024 * 1024;
22
+ const CLEANUP_TIMEOUT_MS = 5_000;
23
+ const IGNORED_ROOT_SEGMENTS = new Set(["node_modules", ".git"]);
26
24
 
27
25
  const hasDotfileSegment = (relativePath) =>
28
26
  relativePath.split(path.sep).some(isDotfile);
29
27
 
30
- const listAllFiles = async (rootDir, currentDir) => {
31
- const entries = await readdir(currentDir, { withFileTypes: true });
32
- const files = [];
33
- for (const entry of entries) {
34
- if (isDotfile(entry.name)) continue;
35
- const abs = path.join(currentDir, entry.name);
36
- if (entry.isDirectory()) {
37
- files.push(...(await listAllFiles(rootDir, abs)));
38
- } else if (entry.isFile()) {
39
- files.push(abs);
40
- }
28
+ const shouldIgnoreWatchedPath = (relativePath, builtTheme) => {
29
+ const normalizedPath = relativePath.split(path.sep).join("/");
30
+ const segments = normalizedPath.split("/");
31
+ const basename = segments.at(-1) ?? "";
32
+
33
+ if (segments.some((segment) => IGNORED_ROOT_SEGMENTS.has(segment))) {
34
+ return true;
41
35
  }
42
- return files;
36
+
37
+ if (!builtTheme && segments[0] === "dist") {
38
+ return true;
39
+ }
40
+
41
+ return basename.endsWith("~") || /\.(swp|tmp|temp)$/i.test(basename);
43
42
  };
44
43
 
45
- const createZipFromDirectory = async (rootDir) => {
46
- const absoluteFiles = await listAllFiles(rootDir, rootDir);
47
- /** @type {Record<string, Uint8Array>} */
48
- const entries = {};
49
- for (const abs of absoluteFiles) {
50
- const rel = path.relative(rootDir, abs).split(path.sep).join("/");
51
- entries[rel] = new Uint8Array(await readFile(abs));
44
+ const shouldRetrySyncResult = (result) =>
45
+ !result.ok && Boolean(result.retriable);
46
+
47
+ const runCleanupStep = async (label, cleanupFn) => {
48
+ if (!cleanupFn) return;
49
+
50
+ let timeoutId = null;
51
+
52
+ try {
53
+ await Promise.race([
54
+ Promise.resolve().then(() => cleanupFn()),
55
+ new Promise((_, reject) => {
56
+ timeoutId = setTimeout(() => {
57
+ reject(new Error(`${label} did not finish within ${CLEANUP_TIMEOUT_MS}ms.`));
58
+ }, CLEANUP_TIMEOUT_MS);
59
+ }),
60
+ ]);
61
+ } catch (error) {
62
+ p.log.warn(error.message);
63
+ } finally {
64
+ if (timeoutId) clearTimeout(timeoutId);
52
65
  }
53
- return Buffer.from(zipSync(entries, { level: 6 }));
54
66
  };
55
67
 
68
+ const uploadFileWithRetries = (
69
+ apiBaseUrl,
70
+ apiKey,
71
+ storeId,
72
+ previewKey,
73
+ relativePath,
74
+ content,
75
+ onRetry,
76
+ ) =>
77
+ retryAsync(
78
+ () =>
79
+ uploadPreviewFileMultipart(
80
+ apiBaseUrl,
81
+ apiKey,
82
+ storeId,
83
+ previewKey,
84
+ relativePath,
85
+ content,
86
+ ),
87
+ {
88
+ attempts: RETRY_ATTEMPTS,
89
+ shouldRetry: shouldRetrySyncResult,
90
+ onRetry,
91
+ },
92
+ );
93
+
94
+ const deleteFileWithRetries = (
95
+ apiBaseUrl,
96
+ apiKey,
97
+ storeId,
98
+ previewKey,
99
+ relativePath,
100
+ onRetry,
101
+ ) =>
102
+ retryAsync(
103
+ () =>
104
+ deletePreviewFile(
105
+ apiBaseUrl,
106
+ apiKey,
107
+ storeId,
108
+ previewKey,
109
+ relativePath,
110
+ ),
111
+ {
112
+ attempts: RETRY_ATTEMPTS,
113
+ shouldRetry: shouldRetrySyncResult,
114
+ onRetry,
115
+ },
116
+ );
117
+
56
118
  export const dev = async () => {
57
119
  const { config, credentials } = await loadConfigOrFail();
58
120
  const { apiBaseUrl, storeId } = config;
@@ -60,6 +122,7 @@ export const dev = async () => {
60
122
  const builtTheme = await isBuiltTheme();
61
123
  const rootDir = builtTheme ? getDistDir() : process.cwd();
62
124
  let buildCleanup = null;
125
+ let localPreviewServer = null;
63
126
 
64
127
  // For built themes, run the build first (with watch mode)
65
128
  if (builtTheme) {
@@ -71,151 +134,178 @@ export const dev = async () => {
71
134
  buildCleanup = buildResult.cleanup;
72
135
  }
73
136
 
74
- const existingPreviewsResult = await listPreviews(
137
+ // Resolve preview via shared interactive picker
138
+ const previewKey = await resolvePreviewKeyInteractively({ config, credentials });
139
+
140
+ // Fetch preview to get hostname for local proxy
141
+ const previewResult = await fetchPreviewDetails(
75
142
  apiBaseUrl,
76
143
  apiKey,
77
144
  storeId,
145
+ previewKey,
78
146
  );
79
- if (!existingPreviewsResult.ok) {
80
- p.log.error(existingPreviewsResult.error);
147
+ if (!previewResult.ok) {
148
+ p.log.error(`Preview ${previewKey} not found.`);
81
149
  process.exit(1);
82
150
  }
83
151
 
84
- let previewKey =
85
- resolveActivePreview(existingPreviewsResult.data, config.previewKey)
86
- ?.previewKey ?? config.previewKey;
87
- let previewUrl;
152
+ const previewHostname = previewResult.data.preview.previewHostname;
153
+ const previewUrl = previewResult.data.url;
88
154
 
89
- if (!previewKey) {
90
- // ── Create preview and do initial upload ─────────────────────────────────
91
- const spinner = p.spinner();
92
- spinner.start("No active preview found. Creating one...");
155
+ const spinner = p.spinner();
156
+ spinner.start("Compressing files...");
93
157
 
94
- const result = await createPreview(apiBaseUrl, apiKey, storeId, "Dev");
95
- if (!result.ok) {
96
- spinner.stop("Failed to create preview.", 1);
97
- p.log.error(result.error);
98
- process.exit(1);
99
- }
158
+ const uploadResult = await pushPreparedDirectoryToPreview({
159
+ apiBaseUrl,
160
+ apiKey,
161
+ storeId,
162
+ previewKey,
163
+ rootDir,
164
+ spinner,
165
+ compressMessage: "Compressing files...",
166
+ retryMessage: (result, nextAttempt) =>
167
+ `Initial push failed. Retrying ${nextAttempt}/${RETRY_ATTEMPTS}... ${result.error}`,
168
+ });
100
169
 
101
- previewKey = result.data.previewKey;
102
- previewUrl = buildPreviewUrl(apiBaseUrl, result.data.previewHostname);
103
- await writeConfig({ ...config, previewKey });
170
+ if (!uploadResult.ok) {
171
+ spinner.stop("Initial push failed.", 1);
172
+ p.log.error(uploadResult.error);
173
+ process.exit(1);
174
+ }
104
175
 
105
- spinner.message("Uploading initial files...");
106
- const zipBuffer = await createZipFromDirectory(rootDir);
107
- const uploadResult = await uploadPreviewZip(
176
+ try {
177
+ localPreviewServer = await startLocalPreviewServer({
108
178
  apiBaseUrl,
109
- apiKey,
110
- storeId,
111
- previewKey,
112
- zipBuffer,
113
- );
114
-
115
- if (!uploadResult.ok) {
116
- spinner.stop("Failed to upload files.", 1);
117
- p.log.error(uploadResult.error);
118
- process.exit(1);
119
- }
120
-
121
- spinner.stop(`Preview ready: ${previewUrl}`);
122
- } else {
123
- // ── Verify existing preview still exists ─────────────────────────────────
124
- const spinner = p.spinner();
125
- spinner.start("Connecting to preview...");
126
-
127
- const listResult = await listPreviews(apiBaseUrl, apiKey, storeId);
128
- if (!listResult.ok) {
129
- spinner.stop("Failed to connect.", 1);
130
- p.log.error(listResult.error);
131
- process.exit(1);
132
- }
133
-
134
- const existing = resolveActivePreview(listResult.data, previewKey);
135
- if (!existing) {
136
- spinner.stop("Could not determine the active preview.", 1);
137
- p.log.error(
138
- listResult.data.length === 0
139
- ? "No previews found for this store. A new preview will be created if you clear the local config and run tiendu dev again."
140
- : "Run tiendu preview list and then set or recreate the preview.",
141
- );
142
- process.exit(1);
143
- }
144
-
145
- previewKey = existing.previewKey;
146
- if (config.previewKey !== previewKey) {
147
- await writeConfig({ ...config, previewKey });
148
- }
179
+ previewHostname,
180
+ });
181
+ } catch (error) {
182
+ p.log.warn(`Could not start local live preview: ${error.message}`);
183
+ }
149
184
 
150
- previewUrl = buildPreviewUrl(apiBaseUrl, existing.previewHostname);
151
- spinner.stop(`Preview: ${previewUrl}`);
185
+ spinner.stop(`Preview ready (${previewKey}).`);
186
+ if (localPreviewServer) {
187
+ p.log.message(`Local live preview: ${localPreviewServer.url}`);
152
188
  }
189
+ p.log.message(`Sharable preview: ${previewUrl}`);
153
190
 
154
- p.log.message("Watching for changes press Ctrl+C to stop.");
191
+ p.log.message("Watching for changes \u2014 press Ctrl+C to stop.");
155
192
 
156
193
  // ── File watcher ──────────────────────────────────────────────────────────
157
194
  /** @type {Map<string, NodeJS.Timeout>} */
158
195
  const debounceMap = new Map();
196
+ const inFlightPaths = new Set();
197
+ const pendingResyncPaths = new Set();
159
198
  const DEBOUNCE_MS = 300;
160
199
 
161
- const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
162
- if (!filename) return;
163
- if (hasDotfileSegment(filename)) return;
164
-
165
- const relativePath = filename.split(path.sep).join("/");
166
- const existing = debounceMap.get(relativePath);
167
- if (existing) clearTimeout(existing);
200
+ const queueSync = (relativePath) => {
201
+ const existingTimer = debounceMap.get(relativePath);
202
+ if (existingTimer) clearTimeout(existingTimer);
168
203
 
169
- const timer = setTimeout(async () => {
204
+ const timer = setTimeout(() => {
170
205
  debounceMap.delete(relativePath);
171
- const absolutePath = path.join(rootDir, filename);
172
-
173
- try {
174
- const fileStat = await stat(absolutePath).catch(() => null);
175
-
176
- if (!fileStat || !fileStat.isFile()) {
177
- if (!fileStat) {
178
- console.log(`✕ ${relativePath}`);
179
- const result = await deletePreviewFile(
180
- apiBaseUrl,
181
- apiKey,
182
- storeId,
183
- previewKey,
184
- relativePath,
185
- );
186
- if (!result.ok) {
187
- p.log.warn(` Failed to delete: ${result.error}`);
188
- }
206
+ void syncPath(relativePath);
207
+ }, DEBOUNCE_MS);
208
+
209
+ debounceMap.set(relativePath, timer);
210
+ };
211
+
212
+ const syncPath = async (relativePath) => {
213
+ if (inFlightPaths.has(relativePath)) {
214
+ pendingResyncPaths.add(relativePath);
215
+ return;
216
+ }
217
+
218
+ inFlightPaths.add(relativePath);
219
+
220
+ try {
221
+ const absolutePath = path.join(rootDir, relativePath);
222
+ const fileStat = await stat(absolutePath).catch(() => null);
223
+
224
+ if (!fileStat || !fileStat.isFile()) {
225
+ if (!fileStat) {
226
+ console.log(`\u2715 ${relativePath}`);
227
+ const result = await deleteFileWithRetries(
228
+ apiBaseUrl,
229
+ apiKey,
230
+ storeId,
231
+ previewKey,
232
+ relativePath,
233
+ async (_, nextAttempt) => {
234
+ p.log.warn(
235
+ ` Retry delete ${relativePath} (${nextAttempt}/${RETRY_ATTEMPTS})`,
236
+ );
237
+ },
238
+ );
239
+
240
+ if (!result.ok) {
241
+ p.log.warn(` Failed to delete after ${RETRY_ATTEMPTS} attempts: ${result.error}`);
242
+ } else {
243
+ localPreviewServer?.notifyReload();
189
244
  }
190
- return;
191
245
  }
192
246
 
193
- console.log(`↑ ${relativePath}`);
194
- const content = await readFile(absolutePath);
195
- const result = await uploadPreviewFileMultipart(
196
- apiBaseUrl,
197
- apiKey,
198
- storeId,
199
- previewKey,
200
- relativePath,
201
- content,
247
+ return;
248
+ }
249
+
250
+ console.log(`\u2191 ${relativePath}`);
251
+ if (fileStat.size > MAX_SYNC_FILE_SIZE_BYTES) {
252
+ p.log.warn(
253
+ ` Skipping ${relativePath}: file is ${(fileStat.size / (1024 * 1024)).toFixed(1)} MB (limit ${(MAX_SYNC_FILE_SIZE_BYTES / (1024 * 1024)).toFixed(0)} MB).`,
202
254
  );
255
+ return;
256
+ }
203
257
 
204
- if (!result.ok) {
205
- p.log.warn(` Failed to upload: ${result.error}`);
206
- }
207
- } catch (error) {
208
- p.log.warn(` Error processing ${relativePath}: ${error.message}`);
258
+ const content = await readFile(absolutePath);
259
+ const result = await uploadFileWithRetries(
260
+ apiBaseUrl,
261
+ apiKey,
262
+ storeId,
263
+ previewKey,
264
+ relativePath,
265
+ content,
266
+ async (_, nextAttempt) => {
267
+ p.log.warn(
268
+ ` Retry upload ${relativePath} (${nextAttempt}/${RETRY_ATTEMPTS})`,
269
+ );
270
+ },
271
+ );
272
+
273
+ if (!result.ok) {
274
+ p.log.warn(` Failed to upload after ${RETRY_ATTEMPTS} attempts: ${result.error}`);
275
+ } else {
276
+ localPreviewServer?.notifyReload();
209
277
  }
210
- }, DEBOUNCE_MS);
278
+ } catch (error) {
279
+ p.log.warn(` Error processing ${relativePath}: ${error.message}`);
280
+ } finally {
281
+ inFlightPaths.delete(relativePath);
211
282
 
212
- debounceMap.set(relativePath, timer);
283
+ if (pendingResyncPaths.delete(relativePath)) {
284
+ queueSync(relativePath);
285
+ }
286
+ }
287
+ };
288
+
289
+ const watcher = watch(rootDir, { recursive: true }, (eventType, filename) => {
290
+ if (!filename) return;
291
+ if (hasDotfileSegment(filename)) return;
292
+ if (shouldIgnoreWatchedPath(filename, builtTheme)) return;
293
+
294
+ const relativePath = filename.split(path.sep).join("/");
295
+ queueSync(relativePath);
213
296
  });
214
297
 
298
+ let cleanedUp = false;
215
299
  const cleanup = async () => {
300
+ if (cleanedUp) return;
301
+ cleanedUp = true;
302
+
216
303
  watcher.close();
217
304
  for (const timer of debounceMap.values()) clearTimeout(timer);
218
- if (buildCleanup) await buildCleanup();
305
+
306
+ await runCleanupStep("Local preview shutdown", () => localPreviewServer?.close());
307
+ await runCleanupStep("Build watcher shutdown", buildCleanup);
308
+
219
309
  p.outro("Dev mode stopped.");
220
310
  process.exit(0);
221
311
  };
@@ -0,0 +1,35 @@
1
+ import { access, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ export const isDotfile = (name) => name.startsWith(".");
5
+
6
+ export const fileExists = async (filePath) => {
7
+ try {
8
+ await access(filePath);
9
+ return true;
10
+ } catch {
11
+ return false;
12
+ }
13
+ };
14
+
15
+ export const listFilesRecursive = async (absoluteDir) => {
16
+ const entries = await readdir(absoluteDir, { withFileTypes: true });
17
+ const files = [];
18
+
19
+ for (const entry of entries) {
20
+ if (isDotfile(entry.name)) continue;
21
+
22
+ const absolutePath = path.join(absoluteDir, entry.name);
23
+
24
+ if (entry.isDirectory()) {
25
+ files.push(...(await listFilesRecursive(absolutePath)));
26
+ continue;
27
+ }
28
+
29
+ if (entry.isFile()) {
30
+ files.push(absolutePath);
31
+ }
32
+ }
33
+
34
+ return files.sort((left, right) => left.localeCompare(right));
35
+ };