@ahmednawaz/crank 0.2.6 → 0.2.8

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/CLAUDE.md CHANGED
@@ -58,7 +58,9 @@ Visual copy tooling that persists text changes to source via the coding agent (M
58
58
  - Secrets: `CRANK_TEAM_TOKEN`
59
59
  - Workflow Run: `npx @ahmednawaz/crank start`
60
60
  - Ports: **3344** (panel), **3345** (`/mcp`)
61
- - If preview is not localhost: `window.__CRANK_URL__` or `VITE_CRANK_URL` = public `https://3344-…` URL
61
+ - Panel loads via same-origin Vite proxy `/@crank/*` (setup registers `crank({ inject: false })`). Restart **Vite** after setup.
62
+ - Fallback: `window.__CRANK_URL__` / `VITE_CRANK_URL` = public `https://3344-…` URL
63
+ - After closing the panel (×), a **Crank** chip appears bottom-right to reopen.
62
64
 
63
65
  ## Apply workflow
64
66
 
package/bin/crank.js CHANGED
@@ -710,12 +710,9 @@ async function main() {
710
710
  });
711
711
  }
712
712
 
713
- // One-shot parity: setup/default always try inject on React apps (idempotent).
714
- if (
715
- (command === 'init' || command === 'run') &&
716
- detectEnv.hasReact &&
717
- flags.mcp !== false
718
- ) {
713
+ // One-shot: always try <Crank /> inject (idempotent). Do not gate on hasReact —
714
+ // root package.json often omits react in monorepos / Replit layouts.
715
+ if ((command === 'init' || command === 'run') && flags.mcp !== false) {
719
716
  const injectResult = injectCrank(projectRoot, { install: true });
720
717
  initResult = initResult || { wrote: [], projectRoot };
721
718
  initResult.inject = injectResult;
@@ -96,10 +96,15 @@
96
96
  function detectCompanionOrigin() {
97
97
  try {
98
98
  const scripts = document.querySelectorAll(
99
- 'script[src*="bookmarklet.js"],script[src*="dev-loader.js"]'
99
+ 'script[src*="bookmarklet.js"],script[src*="dev-loader.js"],script[data-crank-loader="1"]'
100
100
  );
101
101
  for (const s of scripts) {
102
- return new URL(s.src, location.href).origin;
102
+ const u = new URL(s.src, location.href);
103
+ // Vite same-origin proxy: /@crank/dev-loader.js → companion base is origin+/@crank
104
+ if (u.pathname.indexOf('/@crank/') === 0 || u.pathname === '/@crank') {
105
+ return u.origin + '/@crank';
106
+ }
107
+ return u.origin;
103
108
  }
104
109
  } catch (_) {}
105
110
  return null;
@@ -2256,14 +2261,47 @@
2256
2261
  document.removeEventListener('keydown', onKey, true);
2257
2262
  }
2258
2263
 
2264
+ function showReopenChip() {
2265
+ if (document.getElementById('vp-crank-reopen')) {
2266
+ return;
2267
+ }
2268
+ const chip = document.createElement('button');
2269
+ chip.id = 'vp-crank-reopen';
2270
+ chip.type = 'button';
2271
+ chip.textContent = 'Crank';
2272
+ chip.setAttribute('aria-label', 'Reopen Crank panel');
2273
+ chip.style.cssText =
2274
+ 'position:fixed;bottom:16px;right:16px;z-index:2147483646;padding:8px 12px;' +
2275
+ 'border-radius:999px;border:1px solid rgba(0,0,0,.12);background:#0f766e;color:#fff;' +
2276
+ 'font:600 12px/1.2 system-ui,sans-serif;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,.18);';
2277
+ chip.addEventListener('click', function () {
2278
+ chip.remove();
2279
+ window.__CRANK_LOADER__ = false;
2280
+ window.__crankActive = false;
2281
+ const s = document.createElement('script');
2282
+ s.src = COMPANION_HTTP + '/bookmarklet.js?t=' + Date.now();
2283
+ s.async = true;
2284
+ s.dataset.crankLoader = '1';
2285
+ document.documentElement.appendChild(s);
2286
+ });
2287
+ document.documentElement.appendChild(chip);
2288
+ }
2289
+
2259
2290
  function teardown() {
2260
2291
  stopSelect();
2261
2292
  restoreOriginal();
2262
2293
  if (socket) socket.close();
2263
- panel.remove();
2264
- highlight.remove();
2294
+ if (panel && panel.parentNode) panel.remove();
2295
+ if (highlight && highlight.parentNode) highlight.remove();
2265
2296
  window.__crankActive = false;
2266
2297
  window.__crankTeardown = null;
2298
+ window.__CRANK_LOADER__ = false;
2299
+ try {
2300
+ document.querySelectorAll('script[data-crank-loader="1"]').forEach(function (el) {
2301
+ el.remove();
2302
+ });
2303
+ } catch (_) {}
2304
+ showReopenChip();
2267
2305
  }
2268
2306
 
2269
2307
  window.__crankTeardown = teardown;
package/lib/detect.js CHANGED
@@ -123,6 +123,91 @@ function packageHasDep(pkg, name) {
123
123
  );
124
124
  }
125
125
 
126
+ const REACT_DEP_NAMES = [
127
+ 'react',
128
+ 'react-dom',
129
+ '@vitejs/plugin-react',
130
+ '@vitejs/plugin-react-swc',
131
+ 'next'
132
+ ];
133
+
134
+ const REACT_ENTRY_HINTS = [
135
+ 'src/main.tsx',
136
+ 'src/main.jsx',
137
+ 'src/index.tsx',
138
+ 'src/index.jsx',
139
+ 'client/src/main.tsx',
140
+ 'app/main.tsx',
141
+ 'main.tsx',
142
+ 'main.jsx',
143
+ 'index.tsx',
144
+ 'index.jsx'
145
+ ];
146
+
147
+ function packageLooksLikeReact(pkg) {
148
+ return REACT_DEP_NAMES.some((name) => packageHasDep(pkg, name));
149
+ }
150
+
151
+ function dirHasNextConfig(dir) {
152
+ return (
153
+ exists(path.join(dir, 'next.config.js')) ||
154
+ exists(path.join(dir, 'next.config.mjs')) ||
155
+ exists(path.join(dir, 'next.config.ts'))
156
+ );
157
+ }
158
+
159
+ function dirHasReactEntryHint(dir) {
160
+ return REACT_ENTRY_HINTS.some((rel) => exists(path.join(dir, rel)));
161
+ }
162
+
163
+ /**
164
+ * True when this directory (or a common app child) looks like a React app.
165
+ * Root package.json often omits react in monorepos / Replit multi-package layouts.
166
+ */
167
+ function detectHasReact(projectRoot, packageJson, viteConfig) {
168
+ if (packageLooksLikeReact(packageJson) || dirHasNextConfig(projectRoot)) {
169
+ return true;
170
+ }
171
+ if (dirHasReactEntryHint(projectRoot)) {
172
+ return true;
173
+ }
174
+
175
+ const dirs = new Set([projectRoot]);
176
+ for (const sub of APP_DIR_CANDIDATES) {
177
+ if (sub === '.') continue;
178
+ dirs.add(path.join(projectRoot, sub));
179
+ }
180
+ if (viteConfig) {
181
+ dirs.add(path.dirname(viteConfig));
182
+ }
183
+
184
+ for (const dir of dirs) {
185
+ if (!exists(dir) || dir === projectRoot) continue;
186
+ const pkg = readJson(path.join(dir, 'package.json'), null);
187
+ if (packageLooksLikeReact(pkg) || dirHasNextConfig(dir) || dirHasReactEntryHint(dir)) {
188
+ return true;
189
+ }
190
+ }
191
+
192
+ // Vite + JSX plugin import in config is a strong React signal
193
+ if (viteConfig && exists(viteConfig)) {
194
+ try {
195
+ const src = fs.readFileSync(viteConfig, 'utf8');
196
+ if (
197
+ /@vitejs\/plugin-react/.test(src) ||
198
+ /\bplugin-react\b/.test(src) ||
199
+ /from ['"]react['"]/.test(src)
200
+ ) {
201
+ return true;
202
+ }
203
+ } catch {
204
+ // ignore
205
+ }
206
+ }
207
+
208
+ return false;
209
+ }
210
+
126
211
  function detectEnvironment(projectRoot) {
127
212
  const env = process.env;
128
213
  const isReplit = Boolean(
@@ -134,16 +219,13 @@ function detectEnvironment(projectRoot) {
134
219
  : null;
135
220
  const viteConfig = findViteConfig(projectRoot);
136
221
  const hasVite = Boolean(viteConfig) || packageHasDep(packageJson, 'vite');
137
- const hasReact =
138
- packageHasDep(packageJson, 'react') ||
139
- exists(path.join(projectRoot, 'next.config.js')) ||
140
- exists(path.join(projectRoot, 'next.config.mjs')) ||
141
- exists(path.join(projectRoot, 'next.config.ts'));
222
+ const hasReact = detectHasReact(projectRoot, packageJson, viteConfig);
142
223
  const hasNext =
143
224
  packageHasDep(packageJson, 'next') ||
144
- exists(path.join(projectRoot, 'next.config.js')) ||
145
- exists(path.join(projectRoot, 'next.config.mjs')) ||
146
- exists(path.join(projectRoot, 'next.config.ts'));
225
+ dirHasNextConfig(projectRoot) ||
226
+ [...APP_DIR_CANDIDATES]
227
+ .filter((s) => s !== '.')
228
+ .some((sub) => dirHasNextConfig(path.join(projectRoot, sub)));
147
229
  const hasWebpack =
148
230
  packageHasDep(packageJson, 'webpack') ||
149
231
  exists(path.join(projectRoot, 'webpack.config.js')) ||
@@ -182,6 +264,8 @@ module.exports = {
182
264
  readJson,
183
265
  writeJson,
184
266
  detectEnvironment,
267
+ detectHasReact,
268
+ packageLooksLikeReact,
185
269
  findViteConfig,
186
270
  VITE_CONFIG_NAMES,
187
271
  APP_DIR_CANDIDATES
package/lib/init.js CHANGED
@@ -12,7 +12,7 @@ const {
12
12
 
13
13
  const PACKAGE_ROOT = path.resolve(__dirname, '..');
14
14
  const { resolveUrls } = require('./urls');
15
- const { installCrankPackage } = require('./wire-react');
15
+ const { installCrankPackage, findEntry } = require('./wire-react');
16
16
 
17
17
  function ensureGitignore(projectRoot) {
18
18
  const file = path.join(projectRoot, '.gitignore');
@@ -228,20 +228,33 @@ function missingNodeImportLines(source) {
228
228
  return NODE_IMPORT_SPECS.filter(({ re }) => !re.test(source)).map(({ line }) => line);
229
229
  }
230
230
 
231
- function ensureCrankInPlugins(source) {
232
- if (/\bcrank\s*\?\s*\[\s*crank\s*\(\s*\)\s*\]/.test(source) || /\[\s*\.\.\.\s*\(\s*crank\s*\?/.test(source)) {
231
+ function ensureCrankInPlugins(source, options = {}) {
232
+ const call = options.inject === false ? 'crank({ inject: false })' : 'crank()';
233
+ const already =
234
+ /\bcrank\s*\?\s*\[\s*crank\s*\(/.test(source) ||
235
+ /\[\s*\.\.\.\s*\(\s*crank\s*\?/.test(source);
236
+ if (already) {
237
+ // Upgrade bare crank() → crank({ inject: false }) when requested (React + proxy).
238
+ if (options.inject === false && /crank\(\s*\)/.test(source) && !/crank\(\s*\{\s*inject\s*:/.test(source)) {
239
+ return {
240
+ source: source.replace(/crank\(\s*\)/g, 'crank({ inject: false })'),
241
+ changed: true,
242
+ ok: true
243
+ };
244
+ }
233
245
  return { source, changed: false, ok: true };
234
246
  }
247
+ const pluginsExpr = `[...(crank ? [${call}] : [])]`;
235
248
  if (/plugins\s*:\s*\[\s*\]/.test(source)) {
236
249
  return {
237
- source: source.replace(/plugins\s*:\s*\[\s*\]/, 'plugins: [...(crank ? [crank()] : [])]'),
250
+ source: source.replace(/plugins\s*:\s*\[\s*\]/, `plugins: ${pluginsExpr}`),
238
251
  changed: true,
239
252
  ok: true
240
253
  };
241
254
  }
242
255
  if (/plugins\s*:\s*\[/.test(source)) {
243
256
  return {
244
- source: source.replace(/plugins\s*:\s*\[/, 'plugins: [...(crank ? [crank()] : []), '),
257
+ source: source.replace(/plugins\s*:\s*\[/, `plugins: [...(crank ? [${call}] : []), `),
245
258
  changed: true,
246
259
  ok: true
247
260
  };
@@ -250,7 +263,7 @@ function ensureCrankInPlugins(source) {
250
263
  return {
251
264
  source: source.replace(
252
265
  /defineConfig\s*\(\s*\{/,
253
- 'defineConfig({\n plugins: [...(crank ? [crank()] : [])],'
266
+ `defineConfig({\n plugins: ${pluginsExpr},`
254
267
  ),
255
268
  changed: true,
256
269
  ok: true
@@ -260,7 +273,7 @@ function ensureCrankInPlugins(source) {
260
273
  return {
261
274
  source: source.replace(
262
275
  /export\s+default\s+\{/,
263
- 'export default {\n plugins: [...(crank ? [crank()] : [])],'
276
+ `export default {\n plugins: ${pluginsExpr},`
264
277
  ),
265
278
  changed: true,
266
279
  ok: true
@@ -270,7 +283,7 @@ function ensureCrankInPlugins(source) {
270
283
  return {
271
284
  source: source.replace(
272
285
  /module\.exports\s*=\s*\{/,
273
- 'module.exports = {\n plugins: [...(crank ? [crank()] : [])],'
286
+ `module.exports = {\n plugins: ${pluginsExpr},`
274
287
  ),
275
288
  changed: true,
276
289
  ok: true
@@ -279,7 +292,7 @@ function ensureCrankInPlugins(source) {
279
292
  return { source, changed: false, ok: false };
280
293
  }
281
294
 
282
- function patchViteConfig(projectRoot) {
295
+ function patchViteConfig(projectRoot, options = {}) {
283
296
  const configPath = findViteConfig(projectRoot);
284
297
  const stubPath = writeVitePluginStub(projectRoot);
285
298
 
@@ -297,6 +310,17 @@ function patchViteConfig(projectRoot) {
297
310
  }
298
311
 
299
312
  if (hasCrankVitePatch(source)) {
313
+ const upgraded = ensureCrankInPlugins(source, options);
314
+ if (upgraded.changed) {
315
+ source = dedupeCrankNodeImports(upgraded.source).source;
316
+ fs.writeFileSync(configPath, source);
317
+ return {
318
+ patched: true,
319
+ reason: 'upgraded-inject-false',
320
+ file: configPath,
321
+ plugin: stubPath
322
+ };
323
+ }
300
324
  return {
301
325
  patched: repaired.changed,
302
326
  reason: repaired.changed ? 'repaired-duplicate-imports' : 'already-present',
@@ -338,7 +362,7 @@ try {
338
362
  source = gracefulBlock + source;
339
363
  }
340
364
 
341
- const plugged = ensureCrankInPlugins(source);
365
+ const plugged = ensureCrankInPlugins(source, options);
342
366
  if (!plugged.ok) {
343
367
  return {
344
368
  patched: false,
@@ -359,7 +383,7 @@ try {
359
383
  if (!/vite-plugin\.cjs/.test(source) && !/_crankPluginPath/.test(source)) {
360
384
  source = gracefulBlock + source;
361
385
  }
362
- const plugged = ensureCrankInPlugins(source);
386
+ const plugged = ensureCrankInPlugins(source, options);
363
387
  if (!plugged.ok) {
364
388
  return {
365
389
  patched: false,
@@ -623,10 +647,22 @@ function initProject(projectRoot, options = {}) {
623
647
  // React apps: <Crank /> only — do not inject the Vite plugin (avoids duplicating
624
648
  // node: imports when vite.config already has Vizpatch-style loaders).
625
649
  // Still repair duplicate imports left by older Crank versions.
626
- if (env.hasReact && options.react !== false) {
650
+ // Treat findEntry hits as React even when root package.json omits the dep.
651
+ const looksLikeReact = env.hasReact || Boolean(findEntry(projectRoot));
652
+ if (looksLikeReact && options.react !== false) {
627
653
  writeReactMountHint(projectRoot, env, results);
628
654
  results.reactMount = true;
629
- if (env.hasVite) {
655
+ // Still register Vite plugin with inject:false — enables /@crank proxy (Replit iframe).
656
+ if (env.hasVite && options.vite !== false) {
657
+ const viteResult = patchViteConfig(projectRoot, { inject: false });
658
+ results.vite = viteResult;
659
+ if (viteResult.patched || viteResult.reason === 'repaired-duplicate-imports') {
660
+ results.wrote.push(path.relative(projectRoot, viteResult.file));
661
+ }
662
+ if (viteResult.plugin) {
663
+ results.wrote.push(path.relative(projectRoot, viteResult.plugin));
664
+ }
665
+ } else if (env.hasVite) {
630
666
  const repaired = repairViteConfigDuplicates(projectRoot);
631
667
  if (repaired.repaired) {
632
668
  results.vite = repaired;
package/lib/loader-url.js CHANGED
@@ -31,6 +31,11 @@ function resolveCompanionLoaderUrl(options = {}) {
31
31
  return String(options.src);
32
32
  }
33
33
 
34
+ // Prefer same-origin Vite proxy path (works inside Replit iframe / CSP).
35
+ if (options.preferProxy !== false && !options.companionUrl && !options.absolute) {
36
+ return '/@crank/dev-loader.js';
37
+ }
38
+
34
39
  const projectRoot = options.projectRoot || findProjectRoot(process.cwd());
35
40
  const runtime = readRuntimeManifest(projectRoot);
36
41
  if (runtime?.companion?.devLoader) {
@@ -49,7 +54,17 @@ function resolveCompanionLoaderUrl(options = {}) {
49
54
  return `${base}/dev-loader.js`;
50
55
  }
51
56
 
57
+ /** Internal companion base for Vite `server.proxy` target (always loopback). */
58
+ function resolveCompanionProxyTarget(options = {}) {
59
+ if (options.target) {
60
+ return String(options.target).replace(/\/$/, '');
61
+ }
62
+ const port = String(options.port || env('PORT', '3344')).replace(/[^0-9]/g, '') || '3344';
63
+ return `http://127.0.0.1:${port}`;
64
+ }
65
+
52
66
  module.exports = {
53
67
  findProjectRoot,
54
- resolveCompanionLoaderUrl
68
+ resolveCompanionLoaderUrl,
69
+ resolveCompanionProxyTarget
55
70
  };
@@ -3,7 +3,7 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { execSync } = require('child_process');
6
- const { exists, readJson } = require('./detect');
6
+ const { exists, readJson, findViteConfig, packageLooksLikeReact } = require('./detect');
7
7
 
8
8
  const APP_DIR_CANDIDATES = [
9
9
  '.',
@@ -37,6 +37,59 @@ function hasFrameworkMarker(dir) {
37
37
  return FRAMEWORK_FILES.some((name) => exists(path.join(dir, name)));
38
38
  }
39
39
 
40
+ function dirPackageLooksLikeReact(dir) {
41
+ return packageLooksLikeReact(readJson(path.join(dir, 'package.json'), null));
42
+ }
43
+
44
+ /**
45
+ * Prefer the real app package (Vite/React) over a bare repo-root package.json.
46
+ */
47
+ function findAppDirectory(repoRoot) {
48
+ const root = path.resolve(repoRoot);
49
+
50
+ // 1. Known app subdirs with a framework config (before root — monorepos)
51
+ for (const rel of APP_DIR_CANDIDATES) {
52
+ if (rel === '.') continue;
53
+ const candidate = path.join(root, rel);
54
+ if (exists(candidate) && hasFrameworkMarker(candidate)) {
55
+ return { appRoot: candidate, reason: `framework in ${rel}/` };
56
+ }
57
+ }
58
+
59
+ // 2. Vite config found via shallow search (e.g. artifacts/<app>/ on Replit)
60
+ const viteConfig = findViteConfig(root);
61
+ if (viteConfig) {
62
+ const viteDir = path.dirname(viteConfig);
63
+ if (viteDir !== root) {
64
+ // Prefer the vite package dir when it looks like React, or root does not.
65
+ if (dirPackageLooksLikeReact(viteDir) || !dirPackageLooksLikeReact(root)) {
66
+ return {
67
+ appRoot: viteDir,
68
+ reason: `vite.config in ${path.relative(root, viteDir) || '.'}`
69
+ };
70
+ }
71
+ }
72
+ }
73
+
74
+ // 3. Root has framework
75
+ if (hasFrameworkMarker(root)) {
76
+ return { appRoot: root, reason: 'framework at repo root' };
77
+ }
78
+
79
+ // 4. Subdir / root package.json
80
+ for (const rel of APP_DIR_CANDIDATES) {
81
+ const candidate = path.join(root, rel === '.' ? '' : rel);
82
+ if (exists(candidate) && exists(path.join(candidate, 'package.json'))) {
83
+ return {
84
+ appRoot: candidate,
85
+ reason: `package.json in ${rel === '.' ? 'root' : rel + '/'}`
86
+ };
87
+ }
88
+ }
89
+
90
+ return { appRoot: root, reason: 'repo root (fallback)' };
91
+ }
92
+
40
93
  function readCrankConfig(startDir) {
41
94
  let dir = path.resolve(startDir);
42
95
  for (let i = 0; i < 12; i++) {
@@ -95,33 +148,6 @@ function findPackageRoot(startDir) {
95
148
  return path.resolve(startDir);
96
149
  }
97
150
 
98
- /**
99
- * Retune-style: find the app directory inside a repo (app/, client/, web/, …).
100
- */
101
- function findAppDirectory(repoRoot) {
102
- const root = path.resolve(repoRoot);
103
- if (hasFrameworkMarker(root)) {
104
- return { appRoot: root, reason: 'framework at repo root' };
105
- }
106
-
107
- for (const rel of APP_DIR_CANDIDATES) {
108
- if (rel === '.') continue;
109
- const candidate = path.join(root, rel);
110
- if (exists(candidate) && hasFrameworkMarker(candidate)) {
111
- return { appRoot: candidate, reason: `framework in ${rel}/` };
112
- }
113
- }
114
-
115
- for (const rel of APP_DIR_CANDIDATES) {
116
- const candidate = path.join(root, rel === '.' ? '' : rel);
117
- if (exists(candidate) && exists(path.join(candidate, 'package.json'))) {
118
- return { appRoot: candidate, reason: `package.json in ${rel === '.' ? 'root' : rel + '/'}` };
119
- }
120
- }
121
-
122
- return { appRoot: root, reason: 'repo root (fallback)' };
123
- }
124
-
125
151
  /**
126
152
  * Resolve where the user's app code lives — no manual path required.
127
153
  * Priority: CRANK_PROJECT_ROOT → .crank/config → git root + app dir → package.json walk → cwd.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahmednawaz/crank",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Internal Motive UX copy iteration: select UI text \u2192 Glean variants \u2192 MCP apply. Cursor, Replit, Claude.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -67,7 +67,8 @@
67
67
  "prepare:publish": "node scripts/prepare-publish.js",
68
68
  "verify:deps": "node scripts/verify-deps.js",
69
69
  "publish:team": "node scripts/prepare-publish.js && node scripts/verify-deps.js && npm publish --access public",
70
- "test:patch-vite": "node scripts/test-patch-vite.js"
70
+ "test:patch-vite": "node scripts/test-patch-vite.js",
71
+ "test:react-detect": "node scripts/test-react-detect.js"
71
72
  },
72
73
  "engines": {
73
74
  "node": ">=18"
package/react.d.ts CHANGED
@@ -11,15 +11,20 @@ export interface CrankProps {
11
11
 
12
12
  export declare function resolveCrankCompanionUrl(props?: CrankProps): string;
13
13
  export declare function resolveCrankLoaderSrc(props?: CrankProps): string;
14
+ /** Infer https://3344-… from the current Replit preview hostname. */
15
+ export declare function detectReplitCompanionUrl(): string | null;
14
16
 
15
17
  /**
16
- * Retune-style mount — injects companion `/dev-loader.js` once. Renders null.
18
+ * Mount — injects companion loader once. Renders null.
19
+ *
20
+ * Prefers same-origin `/@crank/dev-loader.js` (Vite proxy) so the panel works
21
+ * inside Replit iframe previews; falls back to public companion / localhost.
17
22
  *
18
23
  * import { Crank } from '@ahmednawaz/crank/react'
19
24
  * <Crank />
20
25
  *
21
- * Optional: `window.__CRANK_URL__` or `VITE_CRANK_URL` for Replit public companion host.
22
- * Keep companion running: `npx @ahmednawaz/crank start`
26
+ * Optional: `window.__CRANK_URL__` or `VITE_CRANK_URL`. Keep companion up:
27
+ * `npx @ahmednawaz/crank start`
23
28
  */
24
29
  export declare function Crank(props?: CrankProps): ReactElement | null;
25
30
  export declare const CrankLoader: typeof Crank;
package/react.js CHANGED
@@ -2,9 +2,10 @@
2
2
 
3
3
  /**
4
4
  * CJS build of @ahmednawaz/crank/react (browser-safe, no Node/fs).
5
- * Vite / modern apps should import the ESM build via package exports "import" condition.
6
5
  */
7
6
 
7
+ const PROXY_LOADER = '/@crank/dev-loader.js';
8
+
8
9
  function getReact() {
9
10
  try {
10
11
  return require('react');
@@ -15,6 +16,22 @@ function getReact() {
15
16
  }
16
17
  }
17
18
 
19
+ function detectReplitCompanionUrl() {
20
+ if (typeof window === 'undefined') {
21
+ return null;
22
+ }
23
+ try {
24
+ const host = window.location.hostname || '';
25
+ if (!/\.replit\.dev$|\.repl\.co$|\.replit\.app$/.test(host)) {
26
+ return null;
27
+ }
28
+ const base = host.replace(/^\d+-/, '');
29
+ return 'https://3344-' + base;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
18
35
  function resolveCrankCompanionUrl(props) {
19
36
  props = props || {};
20
37
  if (typeof window !== 'undefined' && window.__CRANK_URL__) {
@@ -26,6 +43,23 @@ function resolveCrankCompanionUrl(props) {
26
43
  if (props.url) {
27
44
  return String(props.url).replace(/\/$/, '');
28
45
  }
46
+ try {
47
+ if (
48
+ typeof process !== 'undefined' &&
49
+ process.env &&
50
+ (process.env.VITE_CRANK_URL || process.env.VITE_CRANK_COMPANION_URL)
51
+ ) {
52
+ return String(
53
+ process.env.VITE_CRANK_URL || process.env.VITE_CRANK_COMPANION_URL
54
+ ).replace(/\/$/, '');
55
+ }
56
+ } catch {
57
+ // ignore
58
+ }
59
+ const replit = detectReplitCompanionUrl();
60
+ if (replit) {
61
+ return replit;
62
+ }
29
63
  return 'http://localhost:3344';
30
64
  }
31
65
 
@@ -34,32 +68,57 @@ function resolveCrankLoaderSrc(props) {
34
68
  if (props.src) {
35
69
  return String(props.src);
36
70
  }
37
- return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
71
+ if (
72
+ typeof window !== 'undefined' &&
73
+ !props.companionUrl &&
74
+ !props.url &&
75
+ !(typeof window !== 'undefined' && window.__CRANK_URL__)
76
+ ) {
77
+ return PROXY_LOADER;
78
+ }
79
+ return resolveCrankCompanionUrl(props) + '/dev-loader.js';
80
+ }
81
+
82
+ function appendLoaderScript(src, onError) {
83
+ const script = document.createElement('script');
84
+ script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
85
+ script.async = true;
86
+ script.dataset.crankLoader = '1';
87
+ if (onError) {
88
+ script.onerror = onError;
89
+ }
90
+ document.documentElement.appendChild(script);
91
+ return script;
38
92
  }
39
93
 
40
94
  function Crank(props) {
41
95
  const React = getReact();
42
96
  props = props || {};
43
- const src = resolveCrankLoaderSrc(props);
97
+ const preferredSrc = resolveCrankLoaderSrc(props);
44
98
 
45
- React.useEffect(() => {
99
+ React.useEffect(function () {
46
100
  if (typeof document === 'undefined') {
47
101
  return undefined;
48
102
  }
49
- if (document.querySelector('script[data-crank-loader="1"]')) {
103
+ if (document.getElementById('vp-shell') || document.getElementById('vp-crank-reopen')) {
50
104
  return undefined;
51
105
  }
52
- if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
106
+ if (document.querySelector('script[data-crank-loader="1"]')) {
53
107
  return undefined;
54
108
  }
55
109
 
56
- const script = document.createElement('script');
57
- script.src = src.indexOf('?') >= 0 ? src : src + '?t=' + Date.now();
58
- script.async = true;
59
- script.dataset.crankLoader = '1';
60
- document.documentElement.appendChild(script);
110
+ const fallback = resolveCrankCompanionUrl(props) + '/dev-loader.js';
111
+ appendLoaderScript(preferredSrc, function () {
112
+ if (preferredSrc !== fallback && !document.getElementById('vp-shell')) {
113
+ document.querySelectorAll('script[data-crank-loader="1"]').forEach(function (el) {
114
+ el.remove();
115
+ });
116
+ window.__CRANK_LOADER__ = false;
117
+ appendLoaderScript(fallback);
118
+ }
119
+ });
61
120
  return undefined;
62
- }, [src]);
121
+ }, [preferredSrc]);
63
122
 
64
123
  return null;
65
124
  }
@@ -67,6 +126,7 @@ function Crank(props) {
67
126
  module.exports = {
68
127
  Crank,
69
128
  CrankLoader: Crank,
129
+ detectReplitCompanionUrl,
70
130
  resolveCrankCompanionUrl,
71
131
  resolveCrankLoaderSrc,
72
132
  default: Crank
package/react.mjs CHANGED
@@ -1,20 +1,41 @@
1
1
  /**
2
- * Retune-style React entry — browser-only, Vite-bundlable (no Node/fs).
2
+ * Browser-safe React entry — Vite-bundlable (no Node/fs).
3
3
  *
4
- * // main.tsx
5
4
  * import { Crank } from '@ahmednawaz/crank/react'
6
- * // or: import { Crank } from '@ahmednawaz/crank'
7
- *
8
5
  * <Crank />
9
6
  *
10
- * Mount near your app root. Injects companion `/dev-loader.js` once (same panel as the
11
- * optional Vite plugin). Requires the companion process: `npx @ahmednawaz/crank start`
7
+ * Loads companion `/dev-loader.js` (prefers same-origin `/@crank` Vite proxy on Replit).
8
+ * Requires: `npx @ahmednawaz/crank start`
12
9
  *
13
10
  * peerDependency: react >= 17
14
11
  */
15
12
 
16
13
  import { useEffect } from 'react';
17
14
 
15
+ const PROXY_LOADER = '/@crank/dev-loader.js';
16
+
17
+ /**
18
+ * Replit public companion host from the current preview hostname.
19
+ * e.g. 5000-foo.replit.dev → https://3344-foo.replit.dev
20
+ * @returns {string | null}
21
+ */
22
+ export function detectReplitCompanionUrl() {
23
+ if (typeof window === 'undefined') {
24
+ return null;
25
+ }
26
+ try {
27
+ const host = window.location.hostname || '';
28
+ if (!/\.replit\.dev$|\.repl\.co$|\.replit\.app$/.test(host)) {
29
+ return null;
30
+ }
31
+ const base = host.replace(/^\d+-/, '');
32
+ const port = 3344;
33
+ return `https://${port}-${base}`;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
18
39
  /**
19
40
  * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
20
41
  * @returns {string}
@@ -40,6 +61,10 @@ export function resolveCrankCompanionUrl(props = {}) {
40
61
  } catch {
41
62
  // ignore
42
63
  }
64
+ const replit = detectReplitCompanionUrl();
65
+ if (replit) {
66
+ return replit;
67
+ }
43
68
  return 'http://localhost:3344';
44
69
  }
45
70
 
@@ -51,38 +76,67 @@ export function resolveCrankLoaderSrc(props = {}) {
51
76
  if (props.src) {
52
77
  return String(props.src);
53
78
  }
79
+ // Same-origin Vite proxy first (Replit iframe / CSP safe).
80
+ if (typeof window !== 'undefined' && !props.companionUrl && !props.url && !window.__CRANK_URL__) {
81
+ try {
82
+ const envUrl =
83
+ typeof import.meta !== 'undefined' &&
84
+ import.meta.env &&
85
+ (import.meta.env.VITE_CRANK_URL || import.meta.env.VITE_CRANK_COMPANION_URL);
86
+ if (!envUrl) {
87
+ return PROXY_LOADER;
88
+ }
89
+ } catch {
90
+ return PROXY_LOADER;
91
+ }
92
+ }
54
93
  return `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
55
94
  }
56
95
 
96
+ function appendLoaderScript(src, onError) {
97
+ const script = document.createElement('script');
98
+ script.src = src.includes('?') ? src : `${src}?t=${Date.now()}`;
99
+ script.async = true;
100
+ script.dataset.crankLoader = '1';
101
+ if (onError) {
102
+ script.onerror = onError;
103
+ }
104
+ document.documentElement.appendChild(script);
105
+ return script;
106
+ }
107
+
57
108
  /**
58
- * Retune-style one-liner. Renders null; injects the companion loader script on the client.
59
- *
60
- * Do not set window.__CRANK_LOADER__ here — `/dev-loader.js` owns that flag and loads the panel.
109
+ * Mount near app root. Renders null; injects the companion loader once.
61
110
  *
62
111
  * @param {{ companionUrl?: string, url?: string, src?: string }} [props]
63
112
  */
64
113
  export function Crank(props = {}) {
65
- const src = resolveCrankLoaderSrc(props);
114
+ const preferredSrc = resolveCrankLoaderSrc(props);
66
115
 
67
116
  useEffect(() => {
68
117
  if (typeof document === 'undefined') {
69
118
  return undefined;
70
119
  }
71
- if (document.querySelector('script[data-crank-loader="1"]')) {
120
+ if (document.getElementById('vp-shell') || document.getElementById('vp-crank-reopen')) {
72
121
  return undefined;
73
122
  }
74
- // Panel already live (bookmarklet / prior mount)
75
- if (window.__CRANK_LOADER__ && document.getElementById('vp-shell')) {
123
+ if (document.querySelector('script[data-crank-loader="1"]')) {
76
124
  return undefined;
77
125
  }
78
126
 
79
- const script = document.createElement('script');
80
- script.src = src.includes('?') ? src : `${src}?t=${Date.now()}`;
81
- script.async = true;
82
- script.dataset.crankLoader = '1';
83
- document.documentElement.appendChild(script);
127
+ const fallback = `${resolveCrankCompanionUrl(props)}/dev-loader.js`;
128
+ const primary = preferredSrc;
129
+
130
+ appendLoaderScript(primary, () => {
131
+ // Proxy missing (no Vite plugin) — fall back to public / localhost companion.
132
+ if (primary !== fallback && !document.getElementById('vp-shell')) {
133
+ document.querySelectorAll('script[data-crank-loader="1"]').forEach((el) => el.remove());
134
+ window.__CRANK_LOADER__ = false;
135
+ appendLoaderScript(fallback);
136
+ }
137
+ });
84
138
  return undefined;
85
- }, [src]);
139
+ }, [preferredSrc]);
86
140
 
87
141
  return null;
88
142
  }
package/vite.js CHANGED
@@ -2,30 +2,55 @@
2
2
 
3
3
  /**
4
4
  * Vite / Rolldown-vite / Vitest-compatible plugin.
5
- * Injects Crank into the app during `vite` / `npm run dev` — no React component needed.
6
5
  *
7
- * // vite.config.ts
8
- * import { crank } from '@ahmednawaz/crank/vite'
9
- * // or: import { crank } from 'crank/vite'
10
- * export default { plugins: [crank()] }
6
+ * - Proxies `/@crank/*` → companion (same-origin; required on Replit iframe previews)
7
+ * - Optionally injects the loader into index.html (skip when using React `<Crank />`)
11
8
  *
12
- * Prefer this for Vite apps (React, Vue, Svelte, vanilla). The panel is a script overlay,
13
- * not a component in your React tree.
9
+ * import { crank } from '@ahmednawaz/crank/vite'
10
+ * export default { plugins: [crank()] } // Vite-only apps
11
+ * export default { plugins: [crank({ inject: false })] } // React + <Crank />
14
12
  */
15
13
 
16
- const { resolveCompanionLoaderUrl } = require('./lib/loader-url');
14
+ const { resolveCompanionLoaderUrl, resolveCompanionProxyTarget } = require('./lib/loader-url');
15
+
16
+ const PROXY_PREFIX = '/@crank';
17
17
 
18
18
  function crank(options = {}) {
19
- const src = resolveCompanionLoaderUrl(options);
19
+ const inject = options.inject !== false;
20
+ const target = resolveCompanionProxyTarget(options);
21
+ const src =
22
+ options.src ||
23
+ `${PROXY_PREFIX}/dev-loader.js`;
20
24
 
21
25
  return {
22
26
  name: 'crank-dev-loader',
23
27
  apply: 'serve',
28
+ config() {
29
+ return {
30
+ server: {
31
+ proxy: {
32
+ [PROXY_PREFIX]: {
33
+ target,
34
+ changeOrigin: true,
35
+ ws: true,
36
+ rewrite: (p) => p.replace(new RegExp(`^${PROXY_PREFIX}`), '') || '/'
37
+ }
38
+ }
39
+ }
40
+ };
41
+ },
24
42
  transformIndexHtml(html) {
25
- if (String(html).includes('/dev-loader.js') || String(html).includes('crank/dev-loader')) {
43
+ if (!inject) {
44
+ return html;
45
+ }
46
+ if (
47
+ String(html).includes('/dev-loader.js') ||
48
+ String(html).includes('crank/dev-loader') ||
49
+ String(html).includes('/@crank/')
50
+ ) {
26
51
  return html;
27
52
  }
28
- const tag = `<script src="${src}" defer></script>`;
53
+ const tag = `<script src="${src}?t=${Date.now()}" defer></script>`;
29
54
  if (html.includes('</head>')) {
30
55
  return html.replace('</head>', ` ${tag}\n</head>`);
31
56
  }
@@ -37,5 +62,6 @@ function crank(options = {}) {
37
62
  module.exports = {
38
63
  crank,
39
64
  crankDevLoader: crank,
40
- resolveCompanionLoaderUrl
65
+ resolveCompanionLoaderUrl,
66
+ PROXY_PREFIX
41
67
  };