shiply-cli 0.25.1 → 0.26.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/dist/bundle.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { cp, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { createHash } from 'node:crypto';
3
+ import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
4
  import { builtinModules } from 'node:module';
4
5
  import { tmpdir } from 'node:os';
5
6
  import { dirname, isAbsolute, join, relative } from 'node:path';
@@ -93,27 +94,109 @@ export async function prepareBundle(sourceDir, ssr, extras = {}) {
93
94
  });
94
95
  return out;
95
96
  }
97
+ /** Fingerprint everything the wrangler dry-run bundle depends on: the build
98
+ * output tree (path/size/mtime — content changes always touch these), the
99
+ * wrangler config, and the project's wrangler version. Returns null when any
100
+ * part can't be read — callers then skip the cache, never the bundle. */
101
+ async function wranglerBundleCacheKey(sourceDir, ssr) {
102
+ try {
103
+ const h = createHash('sha256');
104
+ let wranglerVersion = '?';
105
+ try {
106
+ const pkg = JSON.parse(await readFile(join(sourceDir, 'node_modules', 'wrangler', 'package.json'), 'utf8'));
107
+ wranglerVersion = pkg.version ?? '?';
108
+ }
109
+ catch {
110
+ return null; // no local wrangler pin → can't trust a cached bundle
111
+ }
112
+ h.update(`wrangler@${wranglerVersion}\n`);
113
+ for (const cfg of ['wrangler.toml', 'wrangler.json', 'wrangler.jsonc']) {
114
+ try {
115
+ h.update(`${cfg}\n`).update(await readFile(join(sourceDir, cfg)));
116
+ }
117
+ catch {
118
+ /* absent config files simply don't contribute */
119
+ }
120
+ }
121
+ // Walk the build-output tree the worker entry lives in (e.g. .open-next/),
122
+ // excluding the static assets subtree — assets don't feed the bundle.
123
+ const root = join(sourceDir, dirname(ssr.workerEntry));
124
+ const excludeAbs = ssr.assetsDir ? join(sourceDir, ssr.assetsDir) : null;
125
+ const walk = async (dir) => {
126
+ const entries = await readdir(dir, { withFileTypes: true });
127
+ entries.sort((a, b) => (a.name < b.name ? -1 : 1));
128
+ for (const e of entries) {
129
+ const abs = join(dir, e.name);
130
+ if (excludeAbs && (abs === excludeAbs || abs.startsWith(excludeAbs + '\\') || abs.startsWith(excludeAbs + '/')))
131
+ continue;
132
+ if (e.isDirectory()) {
133
+ await walk(abs);
134
+ }
135
+ else if (e.isFile()) {
136
+ const s = await stat(abs);
137
+ h.update(`${relative(root, abs)}\0${s.size}\0${Math.round(s.mtimeMs)}\n`);
138
+ }
139
+ }
140
+ };
141
+ await walk(root);
142
+ return h.digest('hex');
143
+ }
144
+ catch {
145
+ return null;
146
+ }
147
+ }
96
148
  /** Next.js (OpenNext) path: let wrangler bundle the worker (its nodejs-compat
97
149
  * bundler converts dynamic require('node:*') to top-level imports — esbuild
98
150
  * can't), then deploy that worker multi-module with a hand-written asset-first
99
- * entry. Two modules: index.js (entry) imports ./worker.js (wrangler's bundle). */
151
+ * entry. Two modules: index.js (entry) imports ./worker.js (wrangler's bundle).
152
+ *
153
+ * The wrangler bundle is CACHED per project (node_modules/.cache/shiply),
154
+ * keyed on the build-output tree + wrangler config/version: the dry-run takes
155
+ * ~20s on a real Next app, and a republish without a rebuild (retry, config
156
+ * or content-elsewhere change) shouldn't pay it again. */
100
157
  async function stageWranglerBundle(sourceDir, ssr, out, bundleDir, extras = {}) {
101
- const wtmp = await mkdtemp(join(tmpdir(), 'shiply-wrangler-'));
102
- // `--dry-run` bundles without deploying (no CF auth needed). Local wrangler
103
- // (OpenNext projects depend on it) is preferred via npx. shell:true → npx.cmd on Windows.
104
- const res = spawnSync('npx', ['wrangler', 'deploy', '--dry-run', '--outdir', wtmp], {
105
- cwd: sourceDir,
106
- encoding: 'utf8',
107
- shell: true,
108
- env: { ...process.env, CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? 'dry-run' },
109
- });
110
- const workerPath = join(wtmp, 'worker.js');
111
- const built = await stat(workerPath).then((s) => s.isFile(), () => false);
112
- if (!built) {
113
- throw new Error(`wrangler could not bundle the ${ssr.framework} worker (is wrangler installed in the project?).\n` +
114
- `${(res.stderr || res.stdout || '').slice(0, 1200)}`);
158
+ const cacheKey = await wranglerBundleCacheKey(sourceDir, ssr);
159
+ const cacheDir = join(sourceDir, 'node_modules', '.cache', 'shiply');
160
+ const cachedWorker = join(cacheDir, 'wrangler-worker.js');
161
+ const cachedKeyFile = join(cacheDir, 'wrangler-worker.key');
162
+ const cacheHit = cacheKey !== null &&
163
+ (await readFile(cachedKeyFile, 'utf8').then((k) => k === cacheKey, () => false)) &&
164
+ (await stat(cachedWorker).then((s) => s.isFile(), () => false));
165
+ if (cacheHit) {
166
+ await cp(cachedWorker, join(bundleDir, 'worker.js'));
167
+ }
168
+ else {
169
+ const wtmp = await mkdtemp(join(tmpdir(), 'shiply-wrangler-'));
170
+ // `--dry-run` bundles without deploying (no CF auth needed). Local wrangler
171
+ // (OpenNext projects depend on it) is preferred via npx. shell:true → npx.cmd on Windows.
172
+ const res = spawnSync('npx', ['wrangler', 'deploy', '--dry-run', '--outdir', wtmp], {
173
+ cwd: sourceDir,
174
+ encoding: 'utf8',
175
+ shell: true,
176
+ env: { ...process.env, CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? 'dry-run' },
177
+ });
178
+ const workerPath = join(wtmp, 'worker.js');
179
+ const built = await stat(workerPath).then((s) => s.isFile(), () => false);
180
+ if (!built) {
181
+ throw new Error(`wrangler could not bundle the ${ssr.framework} worker (is wrangler installed in the project?).\n` +
182
+ `${(res.stderr || res.stdout || '').slice(0, 1200)}`);
183
+ }
184
+ await cp(workerPath, join(bundleDir, 'worker.js'));
185
+ if (cacheKey !== null) {
186
+ // Best-effort: populate the cache for the next publish. Write the worker
187
+ // FIRST so a torn write can only produce a stale-key miss, never a hit
188
+ // on a half-written worker.
189
+ try {
190
+ await mkdir(cacheDir, { recursive: true });
191
+ await cp(workerPath, cachedWorker);
192
+ await writeFile(cachedKeyFile, cacheKey);
193
+ }
194
+ catch {
195
+ /* cache is an optimization — never fail the publish over it */
196
+ }
197
+ }
198
+ await rm(wtmp, { recursive: true, force: true });
115
199
  }
116
- await cp(workerPath, join(bundleDir, 'worker.js'));
117
200
  // Asset-first entry that defers to wrangler's worker. Hand-written (not
118
201
  // esbuild) so we never re-process wrangler's correctly-bundled output.
119
202
  // OpenNext's worker ALSO calls env.ASSETS itself and treats any non-404 as an
@@ -150,7 +233,6 @@ async function stageWranglerBundle(sourceDir, ssr, out, bundleDir, extras = {})
150
233
  compatibilityFlags: ssr.compatibilityFlags,
151
234
  isr: extras.isr,
152
235
  });
153
- await rm(wtmp, { recursive: true, force: true });
154
236
  }
155
237
  async function writeManifest(out, m) {
156
238
  // Drop an undefined `isr` so static/non-Next bundles keep a clean manifest.
package/dist/index.js CHANGED
@@ -409,11 +409,30 @@ async function main() {
409
409
  anonymous: res.anonymous,
410
410
  expiresAt: res.expiresAt,
411
411
  updated: updating,
412
+ ...(res.function ? { function: res.function } : {}),
412
413
  })}\n`);
413
414
  return;
414
415
  }
415
416
  const skipped = res.skipped > 0 ? ` (${res.skipped} unchanged, skipped)` : '';
416
417
  console.log(`✔ ${updating ? `updated ${res.slug} in place` : 'published'} — ${res.uploaded} file${res.uploaded === 1 ? '' : 's'}${skipped}`);
418
+ // A failed function/bundle deploy does NOT fail the publish (static
419
+ // files still went live) — but a worker-backed site is broken without
420
+ // its worker, so surface the server's errors instead of swallowing them.
421
+ if (res.function?.errors?.length) {
422
+ console.error(`\n✖ function deploy FAILED — the site's worker is NOT live:`);
423
+ for (const err of res.function.errors)
424
+ console.error(` ${err}`);
425
+ // A worker source was present but did not deploy: the site is not what
426
+ // the user shipped, so exit non-zero. Static files DID go live, hence
427
+ // we still print the URL below — but automation/CI must see the failure.
428
+ process.exitCode = 1;
429
+ }
430
+ else if (res.function?.deployed) {
431
+ const crons = res.function.cronCount ? ` (${res.function.cronCount} cron${res.function.cronCount === 1 ? '' : 's'})` : '';
432
+ console.log(` function deployed${crons}`);
433
+ }
434
+ for (const hint of res.function?.hints ?? [])
435
+ console.log(` ${hint}`);
417
436
  console.log(`\n ${res.siteUrl}\n`);
418
437
  // confirm the site actually serves, then celebrate
419
438
  try {
package/dist/manifest.js CHANGED
@@ -35,14 +35,32 @@ const MIME = {
35
35
  };
36
36
  export const contentTypeFor = (path) => MIME[extname(path).toLowerCase()] ?? 'application/octet-stream';
37
37
  const SKIP_DIRS = new Set(['node_modules']);
38
+ /** Reads+hashes run concurrently — hashing a large build serially left ~1-3s
39
+ * on the table for a few hundred files. */
40
+ const HASH_CONCURRENCY = 8;
38
41
  /** Walk a directory into the publish manifest: posix-relative paths, byte
39
42
  * sizes, sha256 hex hashes (server hash-skips unchanged files on update).
40
43
  * Dot entries and node_modules are never published — except the .shiply/
41
44
  * config directory (proxy.json, data.json), which the server consumes. */
42
45
  export async function buildManifest(dir) {
43
- const out = [];
46
+ const targets = [];
44
47
  const ignore = await loadIgnore(dir);
45
- await walk(dir, '', out, ignore);
48
+ await walk(dir, '', targets, ignore);
49
+ const out = new Array(targets.length);
50
+ let next = 0;
51
+ const worker = async () => {
52
+ while (next < targets.length) {
53
+ const i = next++;
54
+ const buf = await readFile(targets[i].abs);
55
+ out[i] = {
56
+ path: targets[i].rel,
57
+ size: buf.length,
58
+ contentType: contentTypeFor(targets[i].rel),
59
+ hash: createHash('sha256').update(buf).digest('hex'),
60
+ };
61
+ }
62
+ };
63
+ await Promise.all(Array.from({ length: Math.min(HASH_CONCURRENCY, targets.length) }, worker));
46
64
  return out.sort((a, b) => a.path.localeCompare(b.path));
47
65
  }
48
66
  async function walk(abs, rel, out, ignore) {
@@ -67,13 +85,7 @@ async function walk(abs, rel, out, ignore) {
67
85
  await walk(childAbs, childRel, out, ignore);
68
86
  }
69
87
  else if (e.isFile()) {
70
- const buf = await readFile(childAbs);
71
- out.push({
72
- path: childRel,
73
- size: buf.length,
74
- contentType: contentTypeFor(e.name),
75
- hash: createHash('sha256').update(buf).digest('hex'),
76
- });
88
+ out.push({ abs: childAbs, rel: childRel });
77
89
  }
78
90
  }
79
91
  }
package/dist/publish.js CHANGED
@@ -52,8 +52,8 @@ export async function fetchWithRetry(url, init, opts = {}) {
52
52
  }
53
53
  throw new Error(`request to ${label} failed after ${attempts} attempts: ${lastErr?.message ?? 'unknown error'}`);
54
54
  }
55
- export async function api(url, init) {
56
- const res = await fetchWithRetry(url, init, { label: `${init.method ?? 'GET'} ${url}` });
55
+ export async function api(url, init, opts = {}) {
56
+ const res = await fetchWithRetry(url, init, { label: `${init.method ?? 'GET'} ${url}`, ...opts });
57
57
  const body = await res.json().catch(() => ({}));
58
58
  if (!res.ok) {
59
59
  const err = body.error;
@@ -87,12 +87,32 @@ export async function publish(dir, opts = {}) {
87
87
  }),
88
88
  });
89
89
  await uploadAll(dir, created.upload.uploads);
90
- await api(created.upload.finalizeUrl, {
91
- method: 'POST',
92
- headers: { 'content-type': 'application/json' },
93
- body: JSON.stringify({ versionId: created.upload.versionId }),
94
- });
90
+ // Finalize deploys any worker bundle server-side, which can exceed the
91
+ // default 60s timeout — an aborted-then-retried finalize used to fail the
92
+ // whole publish with "already finalized" even though the deploy succeeded.
93
+ // Give it room, and treat that conflict as the success it is (an earlier
94
+ // attempt completed after the client gave up on it).
95
+ // Say what's happening — finalize is one server-side call (file verification
96
+ // + worker deploy) and used to be a silent multi-minute gap for big bundles.
97
+ process.stderr.write(' ⋯ finalizing — server is verifying files and deploying the worker…\n');
98
+ let finalized;
99
+ try {
100
+ finalized = await api(created.upload.finalizeUrl, {
101
+ method: 'POST',
102
+ headers: { 'content-type': 'application/json' },
103
+ body: JSON.stringify({ versionId: created.upload.versionId }),
104
+ }, { timeoutMs: 300_000 });
105
+ }
106
+ catch (e) {
107
+ if (e instanceof ApiError && e.code === 'conflict' && /already finalized/.test(e.message)) {
108
+ finalized = {}; // deployed by the earlier attempt; function info unavailable
109
+ }
110
+ else {
111
+ throw e;
112
+ }
113
+ }
95
114
  return {
115
+ ...(finalized.function ? { function: finalized.function } : {}),
96
116
  siteId: created.siteId,
97
117
  slug: created.slug,
98
118
  siteUrl: created.siteUrl,
@@ -77,12 +77,13 @@ export async function detectSsr(dir) {
77
77
  // (esbuild shims it). The wrangler path applies asset-first in its own entry.
78
78
  bundleWith: 'wrangler',
79
79
  compatibilityFlags: ['nodejs_compat', 'global_fetch_strictly_public'],
80
- // OpenNext imports `node:http`, which `nodejs_compat` only provides at a
81
- // compat date >= ~2025-09 (verified: 2025-03-25 fails "No such module
82
- // node:http", 2025-09-01 succeeds). OpenNext's own config bakes an older
83
- // date that passes `wrangler --dry-run` (which doesn't validate runtime
84
- // modules) but fails the real Workers upload so we override it here.
85
- compatibilityDate: '2025-09-01',
80
+ // OpenNext needs a modern compat date for its node:* imports: 2025-03-25
81
+ // fails "No such module node:http"; @opennextjs/cloudflare 1.20.x also
82
+ // imports `node:fs`, which 2025-09-01 still lacks (real upload fails
83
+ // wrangler --dry-run doesn't validate runtime modules, and OpenNext's own
84
+ // config bakes an even older date). 2026-06-01 verified live with a
85
+ // Next 15 + OpenNext 1.20 deploy — so we override it here.
86
+ compatibilityDate: '2026-06-01',
86
87
  };
87
88
  }
88
89
  // Generic Cloudflare Worker via a wrangler config (Hono, itty, raw fetch).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.25.1",
4
- "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
3
+ "version": "0.26.0",
4
+ "description": "Publish static sites to shiply.now from the command line \u00e2\u20ac\u201d instant web hosting for agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
@@ -6,6 +6,21 @@ entry here, that capability may be missing from your copy — re-install with
6
6
  (This is the agent-facing capability log; the CLI's own release notes live in
7
7
  the npm package changelog.)
8
8
 
9
+ ## 2026-07-07
10
+
11
+ - **Fix** — custom domains attached to SSR/function sites no longer 404: the
12
+ platform's per-site access gate resolved requests by the wrong hostname on
13
+ custom-domain dispatch. No action needed; already-deployed sites are fixed
14
+ server-side, and the next publish bakes in the hardened gate.
15
+ - **Fix** — `shiply publish` now surfaces server-side function/bundle deploy
16
+ errors and hints (previously discarded — a broken worker deploy looked like
17
+ a successful publish). Also included in `--json` output as `function`.
18
+ - **Fix** — Next.js (OpenNext) deploys use compatibility date `2026-06-01`
19
+ (@opennextjs/cloudflare 1.20.x imports `node:fs`; the old date failed the
20
+ Workers upload with "No such module node:fs").
21
+ - **Fix** — long finalize calls (large worker bundles) no longer abort at 60s
22
+ and mis-report a successful deploy as "already finalized".
23
+
9
24
  ## 2026-07-04
10
25
 
11
26
  - **Update** — the site-relative endpoints (`POST /.shiply/data/<collection>`,