shiply-cli 0.18.3 → 0.19.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,18 +1,25 @@
1
- import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
1
+ import { spawnSync } from 'node:child_process';
2
+ import { cp, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { builtinModules } from 'node:module';
2
4
  import { tmpdir } from 'node:os';
3
5
  import { dirname, isAbsolute, join, relative } from 'node:path';
4
6
  import { build } from 'esbuild';
5
7
  const BUNDLE_COMPAT_DATE = '2025-05-05';
6
- /** Cloudflare runtime built-ins (`cloudflare:workers`, …) and `node:*` (provided
7
- * by `nodejs_compat`) resolve at the edge — they must stay external. Everything
8
- * else, including the framework server code that an adapter's `_worker.js`
9
- * imports from OUTSIDE its output dir, gets inlined. */
10
- const WORKER_EXTERNALS = ['cloudflare:*', 'node:*'];
11
- /** esbuild-bundle a worker entry into one self-contained ESM module.
12
- * When `wrapDefaultFetch` is set, the entry exports a NAMED `fetch` (Qwik's
13
- * Pages adapter); we bundle a synthetic entry that re-exports it as the Workers
14
- * `export default { fetch }` shape. */
15
- async function bundleWorker(entryAbs, wrapDefaultFetch = false) {
8
+ /** Cloudflare runtime built-ins (`cloudflare:workers`, …) and Node builtins
9
+ * (provided by `nodejs_compat`) resolve at the edge — they must stay external.
10
+ * Node builtins must be matched both `node:`-prefixed AND BARE (`require('fs')`):
11
+ * OpenNext / Next.js imports them bare, which `node:*` alone doesn't catch.
12
+ * Derived from `builtinModules` so it stays correct across Node versions. Safe
13
+ * for every framework `platform: 'neutral'` never inlines builtins anyway.
14
+ * Everything else (framework server code an adapter imports) gets inlined. */
15
+ const WORKER_EXTERNALS = [
16
+ 'cloudflare:*',
17
+ 'node:*',
18
+ ...builtinModules.flatMap((m) => [m, `${m}/*`]),
19
+ ];
20
+ /** esbuild-bundle a worker entry into one self-contained ESM module, optionally
21
+ * wrapping it (named-fetch → default, and/or asset-first) via a synthetic entry. */
22
+ async function bundleWorker(entryAbs, opts = {}) {
16
23
  const common = {
17
24
  bundle: true,
18
25
  format: 'esm',
@@ -22,16 +29,24 @@ async function bundleWorker(entryAbs, wrapDefaultFetch = false) {
22
29
  write: false,
23
30
  logLevel: 'silent',
24
31
  };
25
- const result = wrapDefaultFetch
26
- ? await build({
27
- ...common,
28
- stdin: {
29
- contents: `import { fetch } from ${JSON.stringify(entryAbs.split('\\').join('/'))}\nexport default { fetch }\n`,
30
- resolveDir: dirname(entryAbs),
31
- loader: 'js',
32
- },
33
- })
34
- : await build({ ...common, entryPoints: [entryAbs] });
32
+ if (!opts.wrapDefaultFetch && !opts.assetFirst) {
33
+ const result = await build({ ...common, entryPoints: [entryAbs] });
34
+ return result.outputFiles[0].text;
35
+ }
36
+ const spec = JSON.stringify(entryAbs.split('\\').join('/'));
37
+ const importLine = opts.wrapDefaultFetch ? `import { fetch as __inner } from ${spec}` : `import __handler from ${spec}`;
38
+ const innerCall = opts.wrapDefaultFetch ? `__inner(request, env, ctx)` : `__handler.fetch(request, env, ctx)`;
39
+ // Asset-first, but ONLY for paths whose last segment has a file extension
40
+ // (real static files like /assets/x.js, /_next/static/y.css). Extension-less
41
+ // paths (/, /about, /api/x) go straight to SSR — otherwise env.ASSETS returns
42
+ // shiply-static's directory LISTING (a 200) for "/" and we'd serve that
43
+ // instead of the SSR page. The 404-fallthrough still covers extensioned
44
+ // dynamic routes (e.g. a generated /sitemap.xml that isn't a static file).
45
+ const assetGuard = opts.assetFirst
46
+ ? `const __seg = (new URL(request.url)).pathname.split('/').pop() || '';\n if (__seg.includes('.') && env && env.ASSETS) { const __a = await env.ASSETS.fetch(request); if (__a.status !== 404) return __a }\n `
47
+ : ``;
48
+ const contents = `${importLine}\nexport default { async fetch(request, env, ctx) {\n ${assetGuard}return ${innerCall}\n } }\n`;
49
+ const result = await build({ ...common, stdin: { contents, resolveDir: dirname(entryAbs), loader: 'js' } });
35
50
  return result.outputFiles[0].text;
36
51
  }
37
52
  /** Stage a temp publish dir: a single bundled worker under `.shiply/bundle/`,
@@ -46,8 +61,15 @@ export async function prepareBundle(sourceDir, ssr) {
46
61
  const out = await mkdtemp(join(tmpdir(), 'shiply-bundle-'));
47
62
  const bundleDir = join(out, '.shiply', 'bundle');
48
63
  await mkdir(bundleDir, { recursive: true });
64
+ if (ssr.bundleWith === 'wrangler') {
65
+ await stageWranglerBundle(sourceDir, ssr, out, bundleDir);
66
+ return out;
67
+ }
49
68
  // Bundle the worker entry into one self-contained ESM module.
50
- await writeFile(join(bundleDir, 'index.js'), await bundleWorker(join(sourceDir, ssr.workerEntry), ssr.wrapDefaultFetch));
69
+ await writeFile(join(bundleDir, 'index.js'), await bundleWorker(join(sourceDir, ssr.workerEntry), {
70
+ wrapDefaultFetch: ssr.wrapDefaultFetch,
71
+ assetFirst: ssr.assetFirst,
72
+ }));
51
73
  // Copy static assets, if any. The worker entry may live inside the assets dir
52
74
  // (SvelteKit, Astro) or in a sibling (Nitro's .output/server vs .output/public);
53
75
  // when inside, drop it so it isn't double-served.
@@ -68,6 +90,64 @@ export async function prepareBundle(sourceDir, ssr) {
68
90
  });
69
91
  return out;
70
92
  }
93
+ /** Next.js (OpenNext) path: let wrangler bundle the worker (its nodejs-compat
94
+ * bundler converts dynamic require('node:*') to top-level imports — esbuild
95
+ * can't), then deploy that worker multi-module with a hand-written asset-first
96
+ * entry. Two modules: index.js (entry) imports ./worker.js (wrangler's bundle). */
97
+ async function stageWranglerBundle(sourceDir, ssr, out, bundleDir) {
98
+ const wtmp = await mkdtemp(join(tmpdir(), 'shiply-wrangler-'));
99
+ // `--dry-run` bundles without deploying (no CF auth needed). Local wrangler
100
+ // (OpenNext projects depend on it) is preferred via npx. shell:true → npx.cmd on Windows.
101
+ const res = spawnSync('npx', ['wrangler', 'deploy', '--dry-run', '--outdir', wtmp], {
102
+ cwd: sourceDir,
103
+ encoding: 'utf8',
104
+ shell: true,
105
+ env: { ...process.env, CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? 'dry-run' },
106
+ });
107
+ const workerPath = join(wtmp, 'worker.js');
108
+ const built = await stat(workerPath).then((s) => s.isFile(), () => false);
109
+ if (!built) {
110
+ throw new Error(`wrangler could not bundle the ${ssr.framework} worker (is wrangler installed in the project?).\n` +
111
+ `${(res.stderr || res.stdout || '').slice(0, 1200)}`);
112
+ }
113
+ await cp(workerPath, join(bundleDir, 'worker.js'));
114
+ // Asset-first entry that defers to wrangler's worker. Hand-written (not
115
+ // esbuild) so we never re-process wrangler's correctly-bundled output.
116
+ // OpenNext's worker ALSO calls env.ASSETS itself and treats any non-404 as an
117
+ // asset — but shiply-static returns a 200 directory LISTING for index-less
118
+ // dirs (e.g. "/"), which OpenNext would serve instead of SSR. So we hand it a
119
+ // GUARDED env.ASSETS that turns an extension-less HTML 200 (a listing) into a
120
+ // 404, making OpenNext SSR those paths. Real assets (with a file extension)
121
+ // pass through unchanged.
122
+ const entry = `import __h from './worker.js'\n` +
123
+ `const __ext = (u) => { try { return ((new URL(u)).pathname.split('/').pop() || '').includes('.') } catch { return false } };\n` +
124
+ `function __guard(env) {\n` +
125
+ ` if (!env || !env.ASSETS) return env;\n` +
126
+ ` const A = { fetch: async (req, ...a) => {\n` +
127
+ ` const url = typeof req === 'string' ? req : req.url;\n` +
128
+ ` const r = await env.ASSETS.fetch(req, ...a);\n` +
129
+ ` if (r.status === 200 && !__ext(url) && (r.headers.get('content-type') || '').includes('text/html')) return new Response(null, { status: 404 });\n` +
130
+ ` return r;\n` +
131
+ ` } };\n` +
132
+ ` return new Proxy(env, { get: (t, p) => (p === 'ASSETS' ? A : t[p]) });\n` +
133
+ `}\n` +
134
+ `export default { async fetch(request, env, ctx) {\n` +
135
+ ` const __seg = (new URL(request.url)).pathname.split('/').pop() || '';\n` +
136
+ ` if (__seg.includes('.') && env && env.ASSETS) { const __a = await env.ASSETS.fetch(request); if (__a.status !== 404) return __a }\n` +
137
+ ` return __h.fetch(request, __guard(env), ctx)\n` +
138
+ ` } }\n`;
139
+ await writeFile(join(bundleDir, 'index.js'), entry);
140
+ if (ssr.assetsDir)
141
+ await cp(join(sourceDir, ssr.assetsDir), out, { recursive: true });
142
+ await writeManifest(out, {
143
+ framework: ssr.framework,
144
+ entry: 'index.js',
145
+ modules: ['index.js', 'worker.js'],
146
+ compatibilityDate: ssr.compatibilityDate ?? BUNDLE_COMPAT_DATE,
147
+ compatibilityFlags: ssr.compatibilityFlags,
148
+ });
149
+ await rm(wtmp, { recursive: true, force: true });
150
+ }
71
151
  async function writeManifest(out, m) {
72
152
  await writeFile(join(out, '.shiply', 'bundle.json'), JSON.stringify({ v: 1, ...m }, null, 2));
73
153
  }
@@ -23,6 +23,7 @@ export async function detectSsr(dir) {
23
23
  framework: 'sveltekit',
24
24
  workerEntry: '.svelte-kit/cloudflare/_worker.js',
25
25
  assetsDir: '.svelte-kit/cloudflare',
26
+ assetFirst: true,
26
27
  compatibilityFlags: NODE_COMPAT,
27
28
  };
28
29
  }
@@ -32,6 +33,7 @@ export async function detectSsr(dir) {
32
33
  framework: 'astro',
33
34
  workerEntry: 'dist/_worker.js/index.js',
34
35
  assetsDir: 'dist',
36
+ assetFirst: true,
35
37
  compatibilityFlags: NODE_COMPAT,
36
38
  };
37
39
  }
@@ -49,12 +51,77 @@ export async function detectSsr(dir) {
49
51
  workerEntry: 'server/entry.cloudflare-pages.js',
50
52
  assetsDir: 'dist',
51
53
  wrapDefaultFetch: true,
54
+ assetFirst: true,
52
55
  compatibilityFlags: NODE_COMPAT,
53
56
  };
54
57
  }
58
+ // React Router v7 (framework mode) — @cloudflare/vite-plugin output. MUST run
59
+ // before detectWrangler: the input wrangler.json `main` points at the SOURCE
60
+ // worker (workers/app.ts importing `virtual:react-router/server-build`), which
61
+ // is NOT esbuild-bundleable; the deployable entry is the built build/server/index.js.
62
+ const reactRouter = await detectReactRouter(dir, d);
63
+ if (reactRouter)
64
+ return reactRouter;
65
+ // Next.js — @opennextjs/cloudflare (OpenNext). Build emits `.open-next/worker.js`
66
+ // (default-export `{ fetch }`) + `.open-next/assets/`. MUST run before
67
+ // detectWrangler (OpenNext also ships a wrangler.jsonc with `main` set). Requires
68
+ // the bundle.ts builtinModules externals (OpenNext imports node builtins by bare
69
+ // name). Build with `assets.run_worker_first: true` so the worker self-serves
70
+ // assets via env.ASSETS (shiply's service-binding model). ISR (R2/D1/DO) deferred.
71
+ if ('@opennextjs/cloudflare' in d && (await exists(join(dir, '.open-next/worker.js')))) {
72
+ return {
73
+ framework: 'next',
74
+ workerEntry: '.open-next/worker.js',
75
+ assetsDir: '.open-next/assets',
76
+ // OpenNext's worker uses dynamic require('node:*') → must be wrangler-bundled
77
+ // (esbuild shims it). The wrangler path applies asset-first in its own entry.
78
+ bundleWith: 'wrangler',
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',
86
+ };
87
+ }
55
88
  // Generic Cloudflare Worker via a wrangler config (Hono, itty, raw fetch).
56
89
  return detectWrangler(dir);
57
90
  }
91
+ /** React Router v7 framework mode (@react-router/dev + @cloudflare/vite-plugin).
92
+ * Anchors on the BUILT artifacts — `build/server/index.js` (the Vite virtual
93
+ * resolved) + `build/client` assets — never the un-bundleable source worker. */
94
+ async function detectReactRouter(dir, d) {
95
+ if (!('@react-router/dev' in d) || !('@cloudflare/vite-plugin' in d))
96
+ return null;
97
+ if (!(await exists(join(dir, 'build/server/index.js'))))
98
+ return null;
99
+ if (!(await exists(join(dir, 'build/server/wrangler.json'))))
100
+ return null;
101
+ if (!(await exists(join(dir, 'build/client'))))
102
+ return null;
103
+ // Pull compat date from the build snapshot; fall back to a known-good modern
104
+ // date (>= the 2024-09-23 modern-nodejs_compat cutoff). The snapshot's
105
+ // assets.directory is an OS-specific '..\\client' string — never use it;
106
+ // hardcode build/client relative to the project dir.
107
+ let compatibilityDate = '2025-10-08';
108
+ try {
109
+ const wj = JSON.parse(await readFile(join(dir, 'build/server/wrangler.json'), 'utf8'));
110
+ if (typeof wj.compatibility_date === 'string')
111
+ compatibilityDate = wj.compatibility_date;
112
+ }
113
+ catch {
114
+ /* keep default */
115
+ }
116
+ return {
117
+ framework: 'react-router',
118
+ workerEntry: 'build/server/index.js',
119
+ assetsDir: 'build/client',
120
+ assetFirst: true,
121
+ compatibilityDate,
122
+ compatibilityFlags: NODE_COMPAT,
123
+ };
124
+ }
58
125
  async function detectNitro(dir) {
59
126
  const nitroJson = join(dir, '.output', 'nitro.json');
60
127
  if (!(await exists(nitroJson)))
@@ -75,6 +142,7 @@ async function detectNitro(dir) {
75
142
  framework: 'nitro',
76
143
  workerEntry: '.output/server/index.mjs',
77
144
  assetsDir: '.output/public',
145
+ assetFirst: true,
78
146
  compatibilityFlags: NODE_COMPAT,
79
147
  };
80
148
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shiply-cli",
3
- "version": "0.18.3",
3
+ "version": "0.19.0",
4
4
  "description": "Publish static sites to shiply.now from the command line — instant web hosting for agents.",
5
5
  "license": "MIT",
6
6
  "type": "module",