shiply-cli 0.18.3 → 0.20.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 +109 -24
- package/dist/functions.js +56 -0
- package/dist/index.js +31 -4
- package/dist/isr.js +50 -0
- package/dist/ssr-adapters.js +68 -0
- package/package.json +1 -1
- package/skill/SKILL.md +3 -2
package/dist/bundle.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
import {
|
|
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
|
|
7
|
-
* by `nodejs_compat`) resolve at the edge — they must stay external.
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
*
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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/`,
|
|
@@ -42,12 +57,19 @@ async function bundleWorker(entryAbs, wrapDefaultFetch = false) {
|
|
|
42
57
|
* self-contained — `_worker.js` imports server code from sibling dirs
|
|
43
58
|
* (`../output/server`, `../cloudflare-tmp`) — so we bundle it exactly as
|
|
44
59
|
* `wrangler deploy` would, rather than upload it as-is. */
|
|
45
|
-
export async function prepareBundle(sourceDir, ssr) {
|
|
60
|
+
export async function prepareBundle(sourceDir, ssr, extras = {}) {
|
|
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, extras);
|
|
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),
|
|
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.
|
|
@@ -65,9 +87,72 @@ export async function prepareBundle(sourceDir, ssr) {
|
|
|
65
87
|
modules: ['index.js'],
|
|
66
88
|
compatibilityDate: ssr.compatibilityDate ?? BUNDLE_COMPAT_DATE,
|
|
67
89
|
compatibilityFlags: ssr.compatibilityFlags,
|
|
90
|
+
isr: extras.isr,
|
|
68
91
|
});
|
|
69
92
|
return out;
|
|
70
93
|
}
|
|
94
|
+
/** Next.js (OpenNext) path: let wrangler bundle the worker (its nodejs-compat
|
|
95
|
+
* bundler converts dynamic require('node:*') to top-level imports — esbuild
|
|
96
|
+
* can't), then deploy that worker multi-module with a hand-written asset-first
|
|
97
|
+
* entry. Two modules: index.js (entry) imports ./worker.js (wrangler's bundle). */
|
|
98
|
+
async function stageWranglerBundle(sourceDir, ssr, out, bundleDir, extras = {}) {
|
|
99
|
+
const wtmp = await mkdtemp(join(tmpdir(), 'shiply-wrangler-'));
|
|
100
|
+
// `--dry-run` bundles without deploying (no CF auth needed). Local wrangler
|
|
101
|
+
// (OpenNext projects depend on it) is preferred via npx. shell:true → npx.cmd on Windows.
|
|
102
|
+
const res = spawnSync('npx', ['wrangler', 'deploy', '--dry-run', '--outdir', wtmp], {
|
|
103
|
+
cwd: sourceDir,
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
shell: true,
|
|
106
|
+
env: { ...process.env, CLOUDFLARE_API_TOKEN: process.env.CLOUDFLARE_API_TOKEN ?? 'dry-run' },
|
|
107
|
+
});
|
|
108
|
+
const workerPath = join(wtmp, 'worker.js');
|
|
109
|
+
const built = await stat(workerPath).then((s) => s.isFile(), () => false);
|
|
110
|
+
if (!built) {
|
|
111
|
+
throw new Error(`wrangler could not bundle the ${ssr.framework} worker (is wrangler installed in the project?).\n` +
|
|
112
|
+
`${(res.stderr || res.stdout || '').slice(0, 1200)}`);
|
|
113
|
+
}
|
|
114
|
+
await cp(workerPath, join(bundleDir, 'worker.js'));
|
|
115
|
+
// Asset-first entry that defers to wrangler's worker. Hand-written (not
|
|
116
|
+
// esbuild) so we never re-process wrangler's correctly-bundled output.
|
|
117
|
+
// OpenNext's worker ALSO calls env.ASSETS itself and treats any non-404 as an
|
|
118
|
+
// asset — but shiply-static returns a 200 directory LISTING for index-less
|
|
119
|
+
// dirs (e.g. "/"), which OpenNext would serve instead of SSR. So we hand it a
|
|
120
|
+
// GUARDED env.ASSETS that turns an extension-less HTML 200 (a listing) into a
|
|
121
|
+
// 404, making OpenNext SSR those paths. Real assets (with a file extension)
|
|
122
|
+
// pass through unchanged.
|
|
123
|
+
const entry = `import __h from './worker.js'\n` +
|
|
124
|
+
`const __ext = (u) => { try { return ((new URL(u)).pathname.split('/').pop() || '').includes('.') } catch { return false } };\n` +
|
|
125
|
+
`function __guard(env) {\n` +
|
|
126
|
+
` if (!env || !env.ASSETS) return env;\n` +
|
|
127
|
+
` const A = { fetch: async (req, ...a) => {\n` +
|
|
128
|
+
` const url = typeof req === 'string' ? req : req.url;\n` +
|
|
129
|
+
` const r = await env.ASSETS.fetch(req, ...a);\n` +
|
|
130
|
+
` if (r.status === 200 && !__ext(url) && (r.headers.get('content-type') || '').includes('text/html')) return new Response(null, { status: 404 });\n` +
|
|
131
|
+
` return r;\n` +
|
|
132
|
+
` } };\n` +
|
|
133
|
+
` return new Proxy(env, { get: (t, p) => (p === 'ASSETS' ? A : t[p]) });\n` +
|
|
134
|
+
`}\n` +
|
|
135
|
+
`export default { async fetch(request, env, ctx) {\n` +
|
|
136
|
+
` const __seg = (new URL(request.url)).pathname.split('/').pop() || '';\n` +
|
|
137
|
+
` if (__seg.includes('.') && env && env.ASSETS) { const __a = await env.ASSETS.fetch(request); if (__a.status !== 404) return __a }\n` +
|
|
138
|
+
` return __h.fetch(request, __guard(env), ctx)\n` +
|
|
139
|
+
` } }\n`;
|
|
140
|
+
await writeFile(join(bundleDir, 'index.js'), entry);
|
|
141
|
+
if (ssr.assetsDir)
|
|
142
|
+
await cp(join(sourceDir, ssr.assetsDir), out, { recursive: true });
|
|
143
|
+
await writeManifest(out, {
|
|
144
|
+
framework: ssr.framework,
|
|
145
|
+
entry: 'index.js',
|
|
146
|
+
modules: ['index.js', 'worker.js'],
|
|
147
|
+
compatibilityDate: ssr.compatibilityDate ?? BUNDLE_COMPAT_DATE,
|
|
148
|
+
compatibilityFlags: ssr.compatibilityFlags,
|
|
149
|
+
isr: extras.isr,
|
|
150
|
+
});
|
|
151
|
+
await rm(wtmp, { recursive: true, force: true });
|
|
152
|
+
}
|
|
71
153
|
async function writeManifest(out, m) {
|
|
72
|
-
|
|
154
|
+
// Drop an undefined `isr` so static/non-Next bundles keep a clean manifest.
|
|
155
|
+
const { isr, ...rest } = m;
|
|
156
|
+
const doc = isr ? { v: 1, ...rest, isr } : { v: 1, ...rest };
|
|
157
|
+
await writeFile(join(out, '.shiply', 'bundle.json'), JSON.stringify(doc, null, 2));
|
|
73
158
|
}
|
package/dist/functions.js
CHANGED
|
@@ -161,6 +161,62 @@ async function removeFnCmd(argv, inherited) {
|
|
|
161
161
|
}
|
|
162
162
|
console.log(`✔ removed worker for ${slug} — static-only routing restored`);
|
|
163
163
|
}
|
|
164
|
+
// --- shiply logs ------------------------------------------------------------
|
|
165
|
+
const LOGS_USAGE = [
|
|
166
|
+
'Usage: shiply logs <slug> [--limit N] [--since MIN] [--json]',
|
|
167
|
+
' shiply logs <slug> # 50 newest events from the last 60 min',
|
|
168
|
+
' shiply logs <slug> --since 1440 # last 24h (max 10080 = 7 days)',
|
|
169
|
+
' shiply logs <slug> --limit 200 --json',
|
|
170
|
+
].join('\n');
|
|
171
|
+
/** Render one log event as a single tail-style line. Pure → unit-testable. */
|
|
172
|
+
export function formatLogLine(e) {
|
|
173
|
+
const ts = new Date(e.timestamp).toISOString();
|
|
174
|
+
const lvl = (e.level || 'info').toUpperCase().padEnd(5);
|
|
175
|
+
const status = e.statusCode ? ` ${e.statusCode}` : '';
|
|
176
|
+
const outcome = e.outcome && e.outcome !== 'ok' ? ` !${e.outcome}` : '';
|
|
177
|
+
const timing = e.wallTimeMs != null ? ` (${e.cpuTimeMs ?? '?'}ms cpu / ${e.wallTimeMs}ms wall)` : '';
|
|
178
|
+
return `${ts} ${lvl} ${e.message}${status}${outcome}${timing}`;
|
|
179
|
+
}
|
|
180
|
+
/** Render a logs payload to the lines `shiply logs` prints: oldest→newest
|
|
181
|
+
* (tail-style, newest at the bottom) + a footer, or a single empty-window hint.
|
|
182
|
+
* The input is newest-first (the API + core both guarantee it), so we reverse.
|
|
183
|
+
* Pure → unit-testable without spawning the process. */
|
|
184
|
+
export function formatLogs(logs, scriptName, slug) {
|
|
185
|
+
if (logs.length === 0) {
|
|
186
|
+
return [
|
|
187
|
+
`no logs for ${slug} in the window — the function may not have been hit recently (try --since 1440)`,
|
|
188
|
+
];
|
|
189
|
+
}
|
|
190
|
+
const lines = [...logs].reverse().map(formatLogLine);
|
|
191
|
+
lines.push(`\n— ${logs.length} event${logs.length === 1 ? '' : 's'} from ${scriptName}`);
|
|
192
|
+
return lines;
|
|
193
|
+
}
|
|
194
|
+
/** `shiply logs <slug>` — read recent runtime logs for a site's per-site Worker
|
|
195
|
+
* (Cloudflare Workers Observability, 7-day retention). Prints chronological
|
|
196
|
+
* (newest at the bottom, tail-style); `--json` dumps the raw payload. */
|
|
197
|
+
export async function runLogs(argv, inherited = {}) {
|
|
198
|
+
const { rest, flags } = readFlags(argv, inherited);
|
|
199
|
+
const slug = rest[0];
|
|
200
|
+
if (!slug) {
|
|
201
|
+
console.error(LOGS_USAGE);
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
const apiKey = await getApiKey(flags, 'logs');
|
|
205
|
+
const base = resolveBase(flags.base);
|
|
206
|
+
const qs = new URLSearchParams();
|
|
207
|
+
if (inherited.limit)
|
|
208
|
+
qs.set('limit', inherited.limit);
|
|
209
|
+
if (inherited.since)
|
|
210
|
+
qs.set('since', inherited.since);
|
|
211
|
+
const suffix = qs.toString() ? `?${qs.toString()}` : '';
|
|
212
|
+
const r = await api(`${base}/api/v1/sites/${slug}/logs${suffix}`, { headers: headers(apiKey) });
|
|
213
|
+
if (flags.json) {
|
|
214
|
+
console.log(JSON.stringify(r, null, 2));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
for (const line of formatLogs(r.logs, r.scriptName, slug))
|
|
218
|
+
console.log(line);
|
|
219
|
+
}
|
|
164
220
|
// --- shiply secret ----------------------------------------------------------
|
|
165
221
|
const SECRET_USAGE = [
|
|
166
222
|
'Usage: shiply secret <set|ls|rm> <slug> [args] [--json]',
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { runDetect } from './detect.js';
|
|
|
10
10
|
import { resolvePayloadDir } from './framework.js';
|
|
11
11
|
import { detectSsr } from './ssr-adapters.js';
|
|
12
12
|
import { prepareBundle } from './bundle.js';
|
|
13
|
+
import { detectIsr } from './isr.js';
|
|
13
14
|
import { runClaimVerify } from './claim.js';
|
|
14
15
|
import { dbAttach, dbBranch, dbBranches, dbCreate, dbDelete, dbDeleteBranch, dbLs, dbMerge, dbMigrate, dbSql, } from './db.js';
|
|
15
16
|
import { driveGet, driveLs, drivePublish, drivePut, driveRm } from './drive.js';
|
|
@@ -18,7 +19,7 @@ import { runProject } from './projects.js';
|
|
|
18
19
|
import { runListing } from './listings.js';
|
|
19
20
|
import { runSendingDomain } from './sending-domains.js';
|
|
20
21
|
import { contractAmend, contractDraft, contractList, contractPdf, contractRetract, contractSend, contractShow, } from './contract.js';
|
|
21
|
-
import { runCron, runFunction, runSecret } from './functions.js';
|
|
22
|
+
import { runCron, runFunction, runLogs, runSecret } from './functions.js';
|
|
22
23
|
import { runEmail } from './email.js';
|
|
23
24
|
import { runMailbox } from './mailbox.js';
|
|
24
25
|
import { loadApiKey, saveApiKey } from './config.js';
|
|
@@ -96,6 +97,9 @@ Usage:
|
|
|
96
97
|
in your worker on next deploy/restart.
|
|
97
98
|
shiply cron <ls|set|rm> <slug> Manage cron triggers on the deployed worker
|
|
98
99
|
shiply cron set <slug> /api/cron/foo "0 9 * * *"
|
|
100
|
+
shiply logs <slug> [--limit N] [--since MIN] Read recent runtime logs for the site's worker
|
|
101
|
+
(Cloudflare Workers Observability, 7-day retention).
|
|
102
|
+
Newest at the bottom; --since in minutes; --json for raw.
|
|
99
103
|
shiply contract <list|draft|show|send|pdf|amend|retract>
|
|
100
104
|
Customer contracts — full lifecycle CLI (B14).
|
|
101
105
|
list <project-id> — parent + amendments table
|
|
@@ -197,7 +201,7 @@ async function main() {
|
|
|
197
201
|
// `--json` is a STRING option (data-insert body). For commands where it's a
|
|
198
202
|
// boolean flag (status/publish/update), detect a BARE --json (no following
|
|
199
203
|
// value) in raw argv and strip it before parseArgs sees it.
|
|
200
|
-
const BARE_JSON_CMDS = new Set(['status', 'publish', 'update']);
|
|
204
|
+
const BARE_JSON_CMDS = new Set(['status', 'publish', 'update', 'logs']);
|
|
201
205
|
const bareJsonFlag = BARE_JSON_CMDS.has(rawArgv[0]) && rawArgv.includes('--json') &&
|
|
202
206
|
!rawArgv.some((a, i) => a === '--json' && typeof rawArgv[i + 1] === 'string' && !rawArgv[i + 1].startsWith('-'));
|
|
203
207
|
if (bareJsonFlag) {
|
|
@@ -235,6 +239,7 @@ async function main() {
|
|
|
235
239
|
'no-confetti': { type: 'boolean' },
|
|
236
240
|
json: { type: 'string' },
|
|
237
241
|
limit: { type: 'string' },
|
|
242
|
+
since: { type: 'string' },
|
|
238
243
|
cursor: { type: 'string' },
|
|
239
244
|
where: { type: 'string' },
|
|
240
245
|
scope: { type: 'string' },
|
|
@@ -283,12 +288,22 @@ async function main() {
|
|
|
283
288
|
let spa;
|
|
284
289
|
let stateDir;
|
|
285
290
|
if (ssr) {
|
|
286
|
-
|
|
291
|
+
// Next ISR demand instrument: measure whether this app asks to
|
|
292
|
+
// revalidate (no infra is provisioned for it yet — those routes serve as
|
|
293
|
+
// live SSR). Records the signal in the bundle for the server-side count
|
|
294
|
+
// and tells the user plainly. Only Next emits a prerender manifest.
|
|
295
|
+
const isr = ssr.framework === 'next' ? ((await detectIsr(dir)) ?? undefined) : undefined;
|
|
296
|
+
stagingDir = await prepareBundle(dir, ssr, { isr });
|
|
287
297
|
publishDir = stagingDir;
|
|
288
298
|
spa = false; // SSR: an asset miss must 404 so the worker handles the route
|
|
289
299
|
stateDir = dir; // persist .shiply.json in the SOURCE dir, not the temp dir
|
|
290
|
-
if (!bareJsonFlag)
|
|
300
|
+
if (!bareJsonFlag) {
|
|
291
301
|
console.log(`✔ ${ssr.framework} (SSR) detected — deploying worker bundle`);
|
|
302
|
+
if (isr && isr.timeBased > 0) {
|
|
303
|
+
console.log(`ℹ Next ISR/revalidate detected on ${isr.timeBased} route(s) (e.g. ${isr.routes[0]}) — ` +
|
|
304
|
+
`shiply serves these as live SSR (re-rendered each request) for now; persistent ISR caching is on the roadmap.`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
292
307
|
}
|
|
293
308
|
else {
|
|
294
309
|
const resolved = await resolvePayloadDir(dir, { framework: values.framework });
|
|
@@ -744,6 +759,18 @@ async function main() {
|
|
|
744
759
|
});
|
|
745
760
|
break;
|
|
746
761
|
}
|
|
762
|
+
case 'logs': {
|
|
763
|
+
// `--json` on `logs` is a BARE flag — stripped from rawArgv before parseArgs
|
|
764
|
+
// (see BARE_JSON_CMDS), so detect it via bareJsonFlag, not values.json.
|
|
765
|
+
await runLogs(positionals.slice(1), {
|
|
766
|
+
base: values.base,
|
|
767
|
+
key: values.key,
|
|
768
|
+
json: bareJsonFlag || (typeof values.json === 'string' && values.json.length > 0),
|
|
769
|
+
limit: values.limit,
|
|
770
|
+
since: values.since,
|
|
771
|
+
});
|
|
772
|
+
break;
|
|
773
|
+
}
|
|
747
774
|
case 'cron': {
|
|
748
775
|
await runCron(positionals.slice(1), {
|
|
749
776
|
base: values.base,
|
package/dist/isr.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
const ROUTE_SAMPLE_CAP = 20;
|
|
4
|
+
/** Parse a Next `prerender-manifest.json` and count routes that want time-based
|
|
5
|
+
* revalidation. Pure + total: any malformed/empty input yields zero demand so a
|
|
6
|
+
* publish can never break on it. On-demand revalidation (revalidateTag/Path) is
|
|
7
|
+
* NOT separately detected here — it doesn't live in the prerender manifest. */
|
|
8
|
+
export function scanIsr(prerenderManifestJson) {
|
|
9
|
+
let doc;
|
|
10
|
+
try {
|
|
11
|
+
doc = JSON.parse(prerenderManifestJson);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { timeBased: 0, routes: [] };
|
|
15
|
+
}
|
|
16
|
+
const buckets = [
|
|
17
|
+
doc?.routes,
|
|
18
|
+
doc?.dynamicRoutes,
|
|
19
|
+
];
|
|
20
|
+
const hits = new Set();
|
|
21
|
+
for (const bucket of buckets) {
|
|
22
|
+
if (!bucket || typeof bucket !== 'object')
|
|
23
|
+
continue;
|
|
24
|
+
for (const [route, meta] of Object.entries(bucket)) {
|
|
25
|
+
const r = meta?.initialRevalidateSeconds;
|
|
26
|
+
if (typeof r === 'number' && Number.isFinite(r) && r > 0)
|
|
27
|
+
hits.add(route);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const routes = [...hits].sort();
|
|
31
|
+
return { timeBased: routes.length, routes: routes.slice(0, ROUTE_SAMPLE_CAP) };
|
|
32
|
+
}
|
|
33
|
+
/** Read a built Next app's prerender manifest and scan it for ISR demand.
|
|
34
|
+
* Returns null when there's no manifest (not a Next build, or build skipped it).
|
|
35
|
+
* Never throws. */
|
|
36
|
+
export async function detectIsr(dir) {
|
|
37
|
+
// `next build` always writes `.next/prerender-manifest.json`; OpenNext keeps
|
|
38
|
+
// `.next` alongside its `.open-next` output. Check both, source-of-truth first.
|
|
39
|
+
for (const rel of ['.next/prerender-manifest.json', '.open-next/.next/prerender-manifest.json']) {
|
|
40
|
+
let text;
|
|
41
|
+
try {
|
|
42
|
+
text = await readFile(join(dir, rel), 'utf8');
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
return scanIsr(text);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
package/dist/ssr-adapters.js
CHANGED
|
@@ -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
package/skill/SKILL.md
CHANGED
|
@@ -557,7 +557,7 @@ secret names (values set via CLI/MCP, never reach the publish payload).
|
|
|
557
557
|
| `remove_function` | Strip function + secrets + crons; fall back to static |
|
|
558
558
|
| `set_secret` / `list_secrets` / `remove_secret` | Manage CF Worker secrets |
|
|
559
559
|
| `set_cron` / `list_crons` / `remove_cron` | Manage cron triggers |
|
|
560
|
-
| `get_function_logs` |
|
|
560
|
+
| `get_function_logs` | Read recent runtime logs (CF Observability, 7-day, newest-first) + a dashboard deep-link |
|
|
561
561
|
|
|
562
562
|
### CLI
|
|
563
563
|
|
|
@@ -567,11 +567,12 @@ shiply function deploy <slug> # alternative: upload worker.js with
|
|
|
567
567
|
shiply function deploy <slug> --ts # uploads worker.ts (server-side compile)
|
|
568
568
|
shiply secret set <slug> STRIPE_KEY sk_xxx # set secret value
|
|
569
569
|
shiply cron set <slug> /api/daily "0 9 * * *"
|
|
570
|
+
shiply logs <slug> # recent worker logs (newest-first); --limit N --since MIN --json
|
|
570
571
|
```
|
|
571
572
|
|
|
572
573
|
### REST
|
|
573
574
|
|
|
574
|
-
`POST/GET/DELETE /api/v1/sites/{slug}/function` · `POST/GET /api/v1/sites/{slug}/secrets` · `DELETE /api/v1/sites/{slug}/secrets/{name}` · `GET/POST/DELETE /api/v1/sites/{slug}/crons`
|
|
575
|
+
`POST/GET/DELETE /api/v1/sites/{slug}/function` · `POST/GET /api/v1/sites/{slug}/secrets` · `DELETE /api/v1/sites/{slug}/secrets/{name}` · `GET/POST/DELETE /api/v1/sites/{slug}/crons` · `GET /api/v1/sites/{slug}/logs?limit&since`
|
|
575
576
|
|
|
576
577
|
### Limits + plan
|
|
577
578
|
|