ninepm 0.36.0 → 0.41.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/README.md +7 -5
- package/dist/deploy-limits.d.ts +10 -0
- package/dist/deploy-limits.js +90 -0
- package/dist/index.d.ts +15 -1
- package/dist/index.js +234 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -86,10 +86,12 @@ Store and share working files:
|
|
|
86
86
|
9pm files put ./report.pdf
|
|
87
87
|
9pm files share <file-id> --expires 24h
|
|
88
88
|
9pm files put ./notes.md --project my-app --path notes/release.md
|
|
89
|
-
9pm files share <file-id> --project my-app --
|
|
89
|
+
9pm files share <file-id> --project my-app --public
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Personal files and project files both count toward the account file storage limit. Share links expire and are capped at 7 days.
|
|
92
|
+
Personal files and project files both count toward the account file storage limit. Share links expire and are capped at 7 days.
|
|
93
|
+
|
|
94
|
+
Share links are **owner-only by default**: the link opens only for you, signed in. Add `--public` when the recipient has no account — that link needs no sign-in, so anyone who obtains the URL can download the file until it expires. Do not use `--public` for anything containing personal data, credentials or a database export.
|
|
93
95
|
|
|
94
96
|
Deploy a first-class Worker runtime app:
|
|
95
97
|
|
|
@@ -186,7 +188,7 @@ Service token secrets returned by `access service create` are also server-side s
|
|
|
186
188
|
9pm files list [--project <site>]
|
|
187
189
|
9pm files put <local-path> [--path <remote-path>] [--project <site>]
|
|
188
190
|
9pm files get <file-id> [--output <path>] [--project <site>]
|
|
189
|
-
9pm files share <file-id> [--expires 24h] [--
|
|
191
|
+
9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
|
|
190
192
|
9pm files delete <file-id> [--project <site>]
|
|
191
193
|
9pm deploy [directory] --runtime worker --entry <path> [--with-db]
|
|
192
194
|
9pm delete <site> --confirm <slug>
|
|
@@ -223,8 +225,8 @@ Options:
|
|
|
223
225
|
- `--project <site>` scopes file commands to an app
|
|
224
226
|
- `--path <remote-path>` stores a file at a specific path
|
|
225
227
|
- `--output <path>` writes a downloaded file to disk
|
|
226
|
-
- `--expires <duration>` sets
|
|
227
|
-
- `--
|
|
228
|
+
- `--expires <duration>` sets share-link expiry, capped at 7 days
|
|
229
|
+
- `--public` makes a share link downloadable by anyone with the URL, no sign-in (share links are owner-only without it)
|
|
228
230
|
- `--confirm <slug>` confirms app deletion by requiring the slug
|
|
229
231
|
- `--provider <google|github|entra>` selects a configured identity provider
|
|
230
232
|
- `--duration <duration>` sets a service token duration
|
package/dist/deploy-limits.d.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
export declare const MAX_STATIC_ASSET_FILE_BYTES: number;
|
|
2
2
|
export declare function formatMebibytes(bytes: number): string;
|
|
3
|
+
export declare function formatMegabytes(bytes: number): string;
|
|
4
|
+
export declare function formatKilobytes(bytes: number): string;
|
|
3
5
|
export declare function assetFileTooLargeMessage(filePath: string, size: number): string;
|
|
4
6
|
export declare function oversizedAssets<T extends {
|
|
5
7
|
path: string;
|
|
6
8
|
size: number;
|
|
7
9
|
}>(files: readonly T[]): T[];
|
|
10
|
+
export declare const MAX_WORKER_SCRIPT_COMPRESSED_BYTES: number;
|
|
11
|
+
export declare const WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES = 100000;
|
|
12
|
+
export declare const MAX_WORKER_ENTRY_COMPRESSED_BYTES: number;
|
|
13
|
+
export declare const WORKER_ENTRY_COMPRESSED_WARN_BYTES: number;
|
|
14
|
+
export declare function workerEntryTooLargeMessage(filePath: string, rawBytes: number, compressedBytes: number): string;
|
|
15
|
+
export declare function workerEntryNearLimitMessage(filePath: string, rawBytes: number, compressedBytes: number): string;
|
|
16
|
+
export declare function workerBundleTooLargeMessage(moduleCount: number, rawBytes: number, compressedBytes: number): string;
|
|
17
|
+
export declare function workerBundleNearLimitMessage(moduleCount: number, rawBytes: number, compressedBytes: number): string;
|
package/dist/deploy-limits.js
CHANGED
|
@@ -20,6 +20,18 @@ export function formatMebibytes(bytes) {
|
|
|
20
20
|
// Whole numbers read cleanly (25 MiB); fractional sizes get one decimal.
|
|
21
21
|
return Number.isInteger(mib) ? `${mib} MiB` : `${mib.toFixed(1)} MiB`;
|
|
22
22
|
}
|
|
23
|
+
// Decimal formatters for the worker script/entry budget. That limit is documented
|
|
24
|
+
// in decimal MB (below), so rendering it in MiB would both misstate the unit and —
|
|
25
|
+
// at 10 MB vs 9.5 MiB — print two different-looking numbers for the same ceiling.
|
|
26
|
+
export function formatMegabytes(bytes) {
|
|
27
|
+
const mb = Math.max(0, bytes) / 1_000_000;
|
|
28
|
+
return Number.isInteger(mb) ? `${mb} MB` : `${mb.toFixed(1)} MB`;
|
|
29
|
+
}
|
|
30
|
+
// Sub-megabyte figures (the wrapper reserve) would render as a misleading "0.1 MB".
|
|
31
|
+
export function formatKilobytes(bytes) {
|
|
32
|
+
const kb = Math.max(0, bytes) / 1000;
|
|
33
|
+
return Number.isInteger(kb) ? `${kb} kB` : `${kb.toFixed(1)} kB`;
|
|
34
|
+
}
|
|
23
35
|
export function assetFileTooLargeMessage(filePath, size) {
|
|
24
36
|
return `File "${filePath}" is ${formatMebibytes(size)}; individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.`;
|
|
25
37
|
}
|
|
@@ -28,3 +40,81 @@ export function assetFileTooLargeMessage(filePath, size) {
|
|
|
28
40
|
export function oversizedAssets(files) {
|
|
29
41
|
return files.filter((file) => file.size > MAX_STATIC_ASSET_FILE_BYTES);
|
|
30
42
|
}
|
|
43
|
+
// The worker-runtime server entry is the one deploy-directory file inlined into
|
|
44
|
+
// the generated Worker script (`buildHostedAppWorker`), so it — unlike every
|
|
45
|
+
// other file, which rides the Workers Assets API — counts against the runtime's
|
|
46
|
+
// script-size budget. That budget is enforced on the *compressed* upload, so
|
|
47
|
+
// this limit is measured on gzipped bytes, not raw ones. Measuring raw bytes is
|
|
48
|
+
// what made `deploy --check` reject bundles the real deploy path published
|
|
49
|
+
// happily (#645): a 2.4 MB entry of inlined SVG/base64 compresses to well under
|
|
50
|
+
// a megabyte, and the platform only ever saw the compressed figure.
|
|
51
|
+
// The runtime's ceiling on the whole generated script, compressed. Documented as
|
|
52
|
+
// "10 MB after compression" on the paid plan:
|
|
53
|
+
// https://developers.cloudflare.com/workers/platform/limits/
|
|
54
|
+
// Read as decimal (10,000,000), not 10 MiB. The doc's unit is ambiguous, so take the
|
|
55
|
+
// smaller reading: a ceiling below the real one only rejects deploys slightly early,
|
|
56
|
+
// while one above it lets an entry pass every local check and still fail the upload.
|
|
57
|
+
// 10 MiB would sit 485,760 bytes high — far more than the wrapper reserve below could
|
|
58
|
+
// absorb, which would defeat the point of having a reserve at all.
|
|
59
|
+
export const MAX_WORKER_SCRIPT_COMPRESSED_BYTES = 10 * 1_000_000;
|
|
60
|
+
// What the app's own code does *not* get to spend: whichever wrapper the platform
|
|
61
|
+
// generates is uploaded as part of the same script. Two consumers spend this reserve,
|
|
62
|
+
// depending on the deploy shape:
|
|
63
|
+
// - single `--entry` workers: `buildHostedAppWorker` (analytics, end-user auth,
|
|
64
|
+
// Access/vanity-host checks, asset fallback), inlined around the entry;
|
|
65
|
+
// - `--bundle` deploys: `buildBundleWrapperModule`, added as the script's main module
|
|
66
|
+
// alongside the bundle's own modules.
|
|
67
|
+
// Both were measured, and both are near-flat across feature combinations: the hosted
|
|
68
|
+
// wrapper is ~36 KB raw / ~12 KB gzipped, the bundle wrapper ~23 KB raw / ~8 KB gzipped.
|
|
69
|
+
// 100 kB is therefore a ~8x margin on the larger of the two, with room for growth in either.
|
|
70
|
+
//
|
|
71
|
+
// Without this reserve, code just under the ceiling passes both `--check` and the pre-create
|
|
72
|
+
// deploy guard, then still blows the real upload once the wrapper is added — failing after
|
|
73
|
+
// the project and slug have been created, which is the exact late failure the preflight
|
|
74
|
+
// exists to prevent.
|
|
75
|
+
export const WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES = 100_000;
|
|
76
|
+
// The budget actually available to the user's entry module.
|
|
77
|
+
export const MAX_WORKER_ENTRY_COMPRESSED_BYTES = MAX_WORKER_SCRIPT_COMPRESSED_BYTES - WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES;
|
|
78
|
+
// Warn once the entry is within 20% of its budget.
|
|
79
|
+
export const WORKER_ENTRY_COMPRESSED_WARN_BYTES = Math.floor(MAX_WORKER_ENTRY_COMPRESSED_BYTES * 0.8);
|
|
80
|
+
// Exact bytes lead, with the rounded figure in parentheses for readability. Rounding
|
|
81
|
+
// alone is not enough at the boundary: a size just over the cap renders identically to
|
|
82
|
+
// the cap at one decimal, so the message read "is 9.9 MB compressed ... limited to
|
|
83
|
+
// 9.9 MB compressed" for anything in 9,900,001..~9,950,000. That is the same
|
|
84
|
+
// two-numbers-you-cannot-reconcile confusion this message exists to remove, and the
|
|
85
|
+
// boundary is exactly where a rejected user lands. Fixed locale so the grouping is
|
|
86
|
+
// stable regardless of the machine's default.
|
|
87
|
+
function exactWithRounded(bytes) {
|
|
88
|
+
return `${bytes.toLocaleString("en-US")} bytes (${formatMegabytes(bytes)})`;
|
|
89
|
+
}
|
|
90
|
+
// Both messages name the measured dimension explicitly. The previous wording
|
|
91
|
+
// ("is 2487048 bytes; limit is 1000000 bytes") gave no way to tell that the two
|
|
92
|
+
// numbers described different things — raw against a compressed-derived cap.
|
|
93
|
+
export function workerEntryTooLargeMessage(filePath, rawBytes, compressedBytes) {
|
|
94
|
+
return (`Worker runtime entry "${filePath}" is ${exactWithRounded(compressedBytes)} compressed, ` +
|
|
95
|
+
`${exactWithRounded(rawBytes)} raw; the entry is limited to ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed ` +
|
|
96
|
+
`(the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit, less ${formatKilobytes(WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES)} reserved for the generated wrapper). ` +
|
|
97
|
+
`Move large static assets out of the entry into sibling files in the deploy directory — they are served ` +
|
|
98
|
+
`from the asset store and do not count toward this limit — or deploy as a container.`);
|
|
99
|
+
}
|
|
100
|
+
export function workerEntryNearLimitMessage(filePath, rawBytes, compressedBytes) {
|
|
101
|
+
return (`Worker runtime entry "${filePath}" is ${exactWithRounded(compressedBytes)} compressed, ` +
|
|
102
|
+
`${exactWithRounded(rawBytes)} raw — over 80% of the ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed entry limit.`);
|
|
103
|
+
}
|
|
104
|
+
// Framework-bundle equivalents (#660). A bundle's modules are uploaded as one script,
|
|
105
|
+
// so the same budget applies to their combined compressed size rather than to any single
|
|
106
|
+
// file — worth saying explicitly, because the per-file asset cap is a different limit and
|
|
107
|
+
// conflating the two is what put a wrong figure in the hosted skill in the first place.
|
|
108
|
+
// The remedy differs too: a bundle's assets already live outside the modules, so the
|
|
109
|
+
// actionable advice is about the server build, not about un-inlining assets.
|
|
110
|
+
export function workerBundleTooLargeMessage(moduleCount, rawBytes, compressedBytes) {
|
|
111
|
+
return (`Worker bundle's ${moduleCount} server module${moduleCount === 1 ? "" : "s"} total ${exactWithRounded(compressedBytes)} compressed, ` +
|
|
112
|
+
`${exactWithRounded(rawBytes)} raw; server code is limited to ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed ` +
|
|
113
|
+
`(the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit, less ${formatKilobytes(WORKER_WRAPPER_COMPRESSED_RESERVE_BYTES)} reserved for the generated wrapper). ` +
|
|
114
|
+
`Files under the bundle's assets directory are served from the asset store and do not count toward this limit, ` +
|
|
115
|
+
`so move what you can there; otherwise trim the server build or deploy as a container.`);
|
|
116
|
+
}
|
|
117
|
+
export function workerBundleNearLimitMessage(moduleCount, rawBytes, compressedBytes) {
|
|
118
|
+
return (`Worker bundle's ${moduleCount} server module${moduleCount === 1 ? "" : "s"} total ${exactWithRounded(compressedBytes)} compressed, ` +
|
|
119
|
+
`${exactWithRounded(rawBytes)} raw — over 80% of the ${exactWithRounded(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed server-code limit.`);
|
|
120
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -103,6 +103,7 @@ export interface DeletedSiteResult {
|
|
|
103
103
|
files: number;
|
|
104
104
|
artifacts: boolean;
|
|
105
105
|
metadata: boolean;
|
|
106
|
+
errors?: string[];
|
|
106
107
|
}
|
|
107
108
|
export interface CreatedRuntimeToken {
|
|
108
109
|
token: string;
|
|
@@ -113,7 +114,7 @@ export interface CreatedServiceToken {
|
|
|
113
114
|
clientSecret: string;
|
|
114
115
|
}
|
|
115
116
|
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
116
|
-
export declare const CLI_VERSION = "0.
|
|
117
|
+
export declare const CLI_VERSION = "0.41.0";
|
|
117
118
|
type CredentialBackend = "macos-keychain" | "windows-dpapi" | "linux-secret-service" | "file-plaintext" | "none";
|
|
118
119
|
export interface CredentialDiagnostics {
|
|
119
120
|
platform: NodeJS.Platform;
|
|
@@ -173,6 +174,11 @@ export interface DeployCheckResult {
|
|
|
173
174
|
errors: string[];
|
|
174
175
|
files: number;
|
|
175
176
|
bytes: number;
|
|
177
|
+
serverCode?: {
|
|
178
|
+
rawBytes: number;
|
|
179
|
+
compressedBytes: number;
|
|
180
|
+
moduleCount: number;
|
|
181
|
+
};
|
|
176
182
|
}
|
|
177
183
|
export type DetectedFramework = "nextjs" | "astro" | "sveltekit" | "vite";
|
|
178
184
|
export declare function detectFramework(directory: string): DetectedFramework | null;
|
|
@@ -187,6 +193,14 @@ export declare function deployDirectory(initialOptions: DeployOptions): Promise<
|
|
|
187
193
|
export type ApiReachability = "reachable" | "blocked";
|
|
188
194
|
export declare function probeApiReachability(apiUrl: string, fetchFn: typeof fetch, timeoutMs?: number): Promise<ApiReachability>;
|
|
189
195
|
export declare function apiUnreachableGuidance(apiUrl: string): string;
|
|
196
|
+
export declare function measureWorkerEntry(source: Uint8Array): {
|
|
197
|
+
rawBytes: number;
|
|
198
|
+
compressedBytes: number;
|
|
199
|
+
};
|
|
200
|
+
export declare function measureWorkerModuleFiles(root: string, modulePaths: readonly string[]): Promise<{
|
|
201
|
+
rawBytes: number;
|
|
202
|
+
compressedBytes: number;
|
|
203
|
+
}>;
|
|
190
204
|
export declare function checkDeployDirectory(options: DeployOptions): Promise<DeployCheckResult>;
|
|
191
205
|
export declare function whoami(options: ApiOptions): Promise<WhoamiResult>;
|
|
192
206
|
export declare function publicWhoamiResult(result: WhoamiResult): PublicWhoamiResult;
|
package/dist/index.js
CHANGED
|
@@ -7,12 +7,14 @@ import os from "node:os";
|
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, openAsBlob, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
+
import { once } from "node:events";
|
|
11
|
+
import { createGzip, gzipSync } from "node:zlib";
|
|
10
12
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
11
13
|
import { MAX_DEPLOY_DESCRIPTION_LENGTH, normalizeDeployDescription } from "./deploy-description.js";
|
|
12
|
-
import { assetFileTooLargeMessage, oversizedAssets } from "./deploy-limits.js";
|
|
14
|
+
import { MAX_STATIC_ASSET_FILE_BYTES, MAX_WORKER_ENTRY_COMPRESSED_BYTES, MAX_WORKER_SCRIPT_COMPRESSED_BYTES, WORKER_ENTRY_COMPRESSED_WARN_BYTES, assetFileTooLargeMessage, formatMebibytes, formatMegabytes, oversizedAssets, workerBundleNearLimitMessage, workerBundleTooLargeMessage, workerEntryNearLimitMessage, workerEntryTooLargeMessage, } from "./deploy-limits.js";
|
|
13
15
|
import { buildFileManifest, readManifestFile } from "./manifest.js";
|
|
14
16
|
import { API_KEY_HELP, API_URL_HELP, CLI_CLIENT_ID, DEFAULT_API_URL, reportCliError } from "./cli-config.js";
|
|
15
|
-
export const CLI_VERSION = "0.
|
|
17
|
+
export const CLI_VERSION = "0.41.0";
|
|
16
18
|
const PUBLIC_DASHBOARD_URL = "https://9pm.ai/dashboard";
|
|
17
19
|
const TOKEN_SERVICE = "9pm";
|
|
18
20
|
const TOKEN_ACCOUNT = "default";
|
|
@@ -21,8 +23,6 @@ const WINDOWS_CREDENTIAL_FILE = "credential.dpapi";
|
|
|
21
23
|
const PLAINTEXT_CREDENTIAL_FILE = "credential.plaintext.json";
|
|
22
24
|
const ALLOW_FILE_CREDENTIAL_STORE_ENV = "NINEPM_ALLOW_FILE_CREDENTIAL_STORE";
|
|
23
25
|
const DISABLE_NATIVE_CREDENTIAL_STORE_ENV = "NINEPM_DISABLE_NATIVE_CREDENTIAL_STORE";
|
|
24
|
-
const WORKER_ENTRY_LIMIT_BYTES = 1_000_000;
|
|
25
|
-
const WORKER_ENTRY_WARN_BYTES = Math.floor(WORKER_ENTRY_LIMIT_BYTES * 0.8);
|
|
26
26
|
const DEFAULT_CONTAINER_POLL_TIMEOUT_MS = 15 * 60 * 1000;
|
|
27
27
|
export function detectFramework(directory) {
|
|
28
28
|
let dependencies = {};
|
|
@@ -273,7 +273,11 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
273
273
|
// across every deploy call — create, upload, finalize, poll — with one wrap.
|
|
274
274
|
const fetchFn = withFetchDiagnostics(options.fetchFn ?? fetch);
|
|
275
275
|
const apiUrl = normalizeApiUrl(options.apiUrl);
|
|
276
|
-
|
|
276
|
+
// A bundle is a worker deploy. Parsing `--bundle` already forces runtime to "worker", so
|
|
277
|
+
// this is a no-op for every CLI invocation; it matters for programmatic callers that pass
|
|
278
|
+
// bundleDir alone, which would otherwise record the deployment as static here while
|
|
279
|
+
// checkDeployDirectory classified the same input as a worker (#661 self-review).
|
|
280
|
+
const runtime = options.runtime ?? (options.entry || options.bundleDir ? "worker" : "static");
|
|
277
281
|
if (!options.runtime && runtime === "static" && hasDockerfile(options.directory)) {
|
|
278
282
|
throw new Error(DOCKERFILE_STATIC_HINT);
|
|
279
283
|
}
|
|
@@ -403,6 +407,8 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
403
407
|
}
|
|
404
408
|
const bundle = options.bundleDir ? bundleMetadataFromManifest(manifest, contentRoot) : null;
|
|
405
409
|
const serverEntry = runtime === "worker" && !bundle ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
|
|
410
|
+
// Set by the size preflight below and reused by the upload loop, so the entry is read once.
|
|
411
|
+
let serverEntryBytes;
|
|
406
412
|
if (serverEntry && !manifest.some((file) => file.path === serverEntry)) {
|
|
407
413
|
throw new Error(`Worker runtime entry file was not found in the deploy directory: ${serverEntry}`);
|
|
408
414
|
}
|
|
@@ -413,6 +419,39 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
413
419
|
if (oversized) {
|
|
414
420
|
throw new Error(assetFileTooLargeMessage(oversized.path, oversized.size));
|
|
415
421
|
}
|
|
422
|
+
// Same worker-entry budget `--check` applies, on the same compressed dimension, so
|
|
423
|
+
// the two paths agree (#645). This rejects only what the runtime would refuse at
|
|
424
|
+
// publish time anyway — it turns an opaque late failure into a local one, before
|
|
425
|
+
// the project is created and the slug claimed.
|
|
426
|
+
if (serverEntry) {
|
|
427
|
+
// Held for the upload below: re-reading the entry there would read a file of up to
|
|
428
|
+
// 25 MiB twice on every worker deploy.
|
|
429
|
+
serverEntryBytes = await readManifestFile(contentRoot, serverEntry);
|
|
430
|
+
const { rawBytes, compressedBytes } = measureWorkerEntry(serverEntryBytes);
|
|
431
|
+
if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
|
|
432
|
+
throw new Error(workerEntryTooLargeMessage(serverEntry, rawBytes, compressedBytes));
|
|
433
|
+
}
|
|
434
|
+
if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
|
|
435
|
+
// Same band, same wording, same channel as `--check` (see the CLI entry point), so a
|
|
436
|
+
// near-limit entry doesn't deploy silently and then get rejected on a slightly
|
|
437
|
+
// larger build with no prior notice.
|
|
438
|
+
console.warn(`Warning: ${workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes)}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Bundles carry the same budget, applied to all their server modules together (#660).
|
|
442
|
+
// Without this the `--bundle` path — the one the hosted skill's size guidance is about —
|
|
443
|
+
// was the only worker shape with no local size check at all, so an over-budget framework
|
|
444
|
+
// build failed at publish instead, after the project and slug existed.
|
|
445
|
+
if (bundle) {
|
|
446
|
+
const modulePaths = bundleModulePaths(bundle);
|
|
447
|
+
const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths);
|
|
448
|
+
if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
|
|
449
|
+
throw new Error(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
|
|
450
|
+
}
|
|
451
|
+
if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
|
|
452
|
+
console.warn(`Warning: ${workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes)}`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
416
455
|
const projectInput = { name: options.name ?? path.basename(path.resolve(options.directory)) };
|
|
417
456
|
const { project, created: projectCreated } = await createProject(fetchFn, apiUrl, options.token, options.slug ? { ...projectInput, slug: options.slug } : projectInput);
|
|
418
457
|
slugHolder.slug = project.slug;
|
|
@@ -448,7 +487,9 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
448
487
|
for (const upload of created.uploads) {
|
|
449
488
|
const file = manifest.find((candidate) => candidate.path === upload.path.split("/").slice(4).map(decodeURIComponent).join("/"));
|
|
450
489
|
const manifestPath = file?.path ?? pathFromUpload(upload, created.deployment.id);
|
|
451
|
-
|
|
490
|
+
// The worker entry was already read for the size preflight above; reuse those bytes
|
|
491
|
+
// rather than reading a file of up to 25 MiB a second time.
|
|
492
|
+
const body = manifestPath === serverEntry && serverEntryBytes ? serverEntryBytes : await readManifestFile(contentRoot, manifestPath);
|
|
452
493
|
const response = await fetchFn(upload.uploadUrl, {
|
|
453
494
|
method: upload.method,
|
|
454
495
|
headers: {
|
|
@@ -469,8 +510,76 @@ async function runDeploy(initialOptions, slugHolder) {
|
|
|
469
510
|
url: finalized.url,
|
|
470
511
|
};
|
|
471
512
|
}
|
|
513
|
+
// Measure the worker-runtime entry the way the platform's script-size budget is
|
|
514
|
+
// actually applied: the entry is inlined into the generated Worker script, and the
|
|
515
|
+
// budget is enforced against the compressed upload. gzipped bytes are therefore
|
|
516
|
+
// the comparable figure; the raw size is carried alongside for the error message
|
|
517
|
+
// only, so a report can name both dimensions instead of silently mixing them.
|
|
518
|
+
//
|
|
519
|
+
// Deliberately zlib's default level rather than 9: the platform's own compression
|
|
520
|
+
// settings are unknown, and a weaker setting yields a slightly *larger* figure, so
|
|
521
|
+
// the guard errs toward rejecting early rather than passing an entry the real upload
|
|
522
|
+
// would refuse. It is also markedly cheaper on a large entry, and this now runs on
|
|
523
|
+
// every worker deploy, not just `--check`.
|
|
524
|
+
export function measureWorkerEntry(source) {
|
|
525
|
+
return { rawBytes: source.byteLength, compressedBytes: gzipSync(source).byteLength };
|
|
526
|
+
}
|
|
527
|
+
// A framework bundle's modules all ride one script upload, so the budget applies to their
|
|
528
|
+
// combined compressed size (#660) — one gzip pass over the concatenation, not the sum of
|
|
529
|
+
// each module's own gzip: separate streams share no dictionary, and on a many-module bundle
|
|
530
|
+
// (OpenNext emits hundreds) summing overstates the total enough to reject a bundle the
|
|
531
|
+
// platform would accept, which is the over-strict failure #645 was about.
|
|
532
|
+
//
|
|
533
|
+
// Streamed rather than concatenated in memory. Only the compressed *length* is needed, so
|
|
534
|
+
// output chunks are counted and dropped, and modules are read one at a time: peak memory is
|
|
535
|
+
// one module plus gzip's window regardless of bundle size. Buffering every module and then
|
|
536
|
+
// copying them into one combined buffer peaked above twice the raw bundle and could be
|
|
537
|
+
// OOM-killed before it managed to report the size error it existed to report.
|
|
538
|
+
//
|
|
539
|
+
// It deliberately does not stop early once the limit is passed. The messages report exact
|
|
540
|
+
// byte counts — that precision is what keeps them unambiguous at the boundary — and a floor
|
|
541
|
+
// like "at least N bytes" would give that up to save a second on an already-failing deploy.
|
|
542
|
+
export async function measureWorkerModuleFiles(root, modulePaths) {
|
|
543
|
+
const gzip = createGzip();
|
|
544
|
+
let rawBytes = 0;
|
|
545
|
+
let compressedBytes = 0;
|
|
546
|
+
// Attaching a data listener puts the stream in flowing mode, so "end" arrives once the
|
|
547
|
+
// whole compressed output has been counted.
|
|
548
|
+
gzip.on("data", (chunk) => {
|
|
549
|
+
compressedBytes += chunk.byteLength;
|
|
550
|
+
});
|
|
551
|
+
const finished = new Promise((resolve, reject) => {
|
|
552
|
+
gzip.once("end", () => resolve());
|
|
553
|
+
gzip.once("error", reject);
|
|
554
|
+
});
|
|
555
|
+
// A read failing mid-bundle leaves the loop without awaiting `finished`. Register a
|
|
556
|
+
// handler now so that path cannot surface as an unhandled rejection; `await finished`
|
|
557
|
+
// below still observes a real error, since a promise may carry several handlers.
|
|
558
|
+
finished.catch(() => { });
|
|
559
|
+
try {
|
|
560
|
+
for (const modulePath of modulePaths) {
|
|
561
|
+
const bytes = await readManifestFile(root, modulePath);
|
|
562
|
+
rawBytes += bytes.byteLength;
|
|
563
|
+
if (!gzip.write(bytes)) {
|
|
564
|
+
// Respect backpressure so a large bundle cannot balloon the stream's internal queue.
|
|
565
|
+
await once(gzip, "drain");
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
gzip.end();
|
|
569
|
+
await finished;
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
// Don't leave a half-written stream alive when a module can't be read.
|
|
573
|
+
gzip.destroy();
|
|
574
|
+
throw error;
|
|
575
|
+
}
|
|
576
|
+
return { rawBytes, compressedBytes };
|
|
577
|
+
}
|
|
472
578
|
export async function checkDeployDirectory(options) {
|
|
473
|
-
|
|
579
|
+
// `--bundle` implies the worker runtime, matching runDeploy's derivation exactly. Without
|
|
580
|
+
// this a bundle check fell through to "static" and silently graded a framework bundle as
|
|
581
|
+
// a static site: no server-code check, and a spurious missing-index.html warning (#660).
|
|
582
|
+
const runtime = options.runtime ?? (options.entry || options.bundleDir ? "worker" : "static");
|
|
474
583
|
if (runtime === "container") {
|
|
475
584
|
const warnings = [];
|
|
476
585
|
const errors = [];
|
|
@@ -486,11 +595,42 @@ export async function checkDeployDirectory(options) {
|
|
|
486
595
|
}
|
|
487
596
|
return { ok: errors.length === 0, warnings, errors, files: 0, bytes: 0 };
|
|
488
597
|
}
|
|
489
|
-
|
|
490
|
-
|
|
598
|
+
// Mirror runDeploy: a bundle deploy reads from `--bundle <dir>` rather than the project
|
|
599
|
+
// root, and drops source maps. Checking the wrong directory is how `--check` and `deploy`
|
|
600
|
+
// drift apart, which is the whole failure mode this guard exists to prevent.
|
|
601
|
+
const contentRoot = options.bundleDir ? path.resolve(options.directory, options.bundleDir) : options.directory;
|
|
602
|
+
if (options.bundleDir) {
|
|
603
|
+
// `--check` reports problems rather than throwing them. Before this path knew about
|
|
604
|
+
// bundles it never touched the bundle directory, so a wrong `--bundle` argument now
|
|
605
|
+
// reaches the manifest walk and would otherwise surface as a raw ENOENT, not a finding.
|
|
606
|
+
const stats = statSync(contentRoot, { throwIfNoEntry: false });
|
|
607
|
+
if (!stats?.isDirectory()) {
|
|
608
|
+
return { ok: false, warnings: [], errors: [`Bundle directory was not found: ${contentRoot}`], files: 0, bytes: 0 };
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
let manifest = await buildFileManifest(contentRoot);
|
|
612
|
+
if (options.bundleDir) {
|
|
613
|
+
manifest = manifest.filter((file) => !file.path.endsWith(".map"));
|
|
614
|
+
}
|
|
491
615
|
const totalBytes = manifest.reduce((sum, file) => sum + file.size, 0);
|
|
492
616
|
const warnings = [];
|
|
493
617
|
const errors = [];
|
|
618
|
+
// Reported even when the check passes, so `--check` actually prints the compressed figure
|
|
619
|
+
// the hosted skill tells agents to read (#661 review). Warnings only fire in the top 20%
|
|
620
|
+
// of the budget, so without this a healthy bundle produced no size information at all.
|
|
621
|
+
let serverCode;
|
|
622
|
+
// bundleMetadataFromManifest throws when the layout is wrong; `--check` reports that as a
|
|
623
|
+
// finding rather than an exception, since reporting problems is its entire job.
|
|
624
|
+
let bundle = null;
|
|
625
|
+
if (options.bundleDir) {
|
|
626
|
+
try {
|
|
627
|
+
bundle = bundleMetadataFromManifest(manifest, contentRoot);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const serverEntry = runtime === "worker" && !options.bundleDir ? normalizeServerEntry(options.entry ?? "src/index.js") : undefined;
|
|
494
634
|
if (manifest.length === 0) {
|
|
495
635
|
errors.push("Directory contains no deployable files.");
|
|
496
636
|
}
|
|
@@ -511,19 +651,35 @@ export async function checkDeployDirectory(options) {
|
|
|
511
651
|
errors.push(`Worker runtime entry file was not found: ${serverEntry}`);
|
|
512
652
|
}
|
|
513
653
|
else {
|
|
514
|
-
|
|
515
|
-
|
|
654
|
+
const bytes = await readManifestFile(contentRoot, serverEntry);
|
|
655
|
+
const { rawBytes, compressedBytes } = measureWorkerEntry(bytes);
|
|
656
|
+
serverCode = { rawBytes, compressedBytes, moduleCount: 1 };
|
|
657
|
+
if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
|
|
658
|
+
errors.push(workerEntryTooLargeMessage(serverEntry, rawBytes, compressedBytes));
|
|
516
659
|
}
|
|
517
|
-
else if (
|
|
518
|
-
warnings.push(
|
|
660
|
+
else if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
|
|
661
|
+
warnings.push(workerEntryNearLimitMessage(serverEntry, rawBytes, compressedBytes));
|
|
519
662
|
}
|
|
520
|
-
const source = new TextDecoder().decode(
|
|
521
|
-
const
|
|
522
|
-
warnings.push(...
|
|
523
|
-
errors.push(...
|
|
663
|
+
const source = new TextDecoder().decode(bytes);
|
|
664
|
+
const entrySource = await validateWorkerEntrySource(source, serverEntry);
|
|
665
|
+
warnings.push(...entrySource.warnings);
|
|
666
|
+
errors.push(...entrySource.errors);
|
|
524
667
|
}
|
|
525
668
|
}
|
|
526
|
-
|
|
669
|
+
// Same server-code budget the real deploy applies to a bundle (#660), measured the same
|
|
670
|
+
// way — all modules compressed together, assets excluded.
|
|
671
|
+
if (bundle) {
|
|
672
|
+
const modulePaths = bundleModulePaths(bundle);
|
|
673
|
+
const { rawBytes, compressedBytes } = await measureWorkerModuleFiles(contentRoot, modulePaths);
|
|
674
|
+
serverCode = { rawBytes, compressedBytes, moduleCount: modulePaths.length };
|
|
675
|
+
if (compressedBytes > MAX_WORKER_ENTRY_COMPRESSED_BYTES) {
|
|
676
|
+
errors.push(workerBundleTooLargeMessage(modulePaths.length, rawBytes, compressedBytes));
|
|
677
|
+
}
|
|
678
|
+
else if (compressedBytes >= WORKER_ENTRY_COMPRESSED_WARN_BYTES) {
|
|
679
|
+
warnings.push(workerBundleNearLimitMessage(modulePaths.length, rawBytes, compressedBytes));
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
return { ok: errors.length === 0, warnings, errors, files: manifest.length, bytes: totalBytes, ...(serverCode ? { serverCode } : {}) };
|
|
527
683
|
}
|
|
528
684
|
export async function whoami(options) {
|
|
529
685
|
return apiRequest(options, "/v1/me", { method: "GET" });
|
|
@@ -1841,7 +1997,22 @@ function parseFilesArgs(argv) {
|
|
|
1841
1997
|
throw new Error("Missing file id.");
|
|
1842
1998
|
}
|
|
1843
1999
|
const expires = readStringFlag(flags, "expires") ?? "24h";
|
|
1844
|
-
|
|
2000
|
+
// Publishing to the open internet is opt-IN: a public share needs no sign-in,
|
|
2001
|
+
// so the bare form must not produce one. `--login-required` is still accepted
|
|
2002
|
+
// (it is now the default) so existing callers keep working. Both flags together
|
|
2003
|
+
// is ambiguous on a control that decides public exposure, so it fails rather
|
|
2004
|
+
// than applying a precedence rule the caller cannot see. See #637.
|
|
2005
|
+
if (flags.has("public") && flags.has("login-required")) {
|
|
2006
|
+
throw new Error("Pass either --public or --login-required, not both.");
|
|
2007
|
+
}
|
|
2008
|
+
// parseFlags stores the next non-`--` token as a flag's value, so `--public false`
|
|
2009
|
+
// sets "false" and `flags.has` is still true — the value reads as a disable and
|
|
2010
|
+
// publishes anyway. Only guarded on this flag: a misread `--login-required <value>`
|
|
2011
|
+
// still resolves to owner-only, which is safe, whereas this one exposes a file.
|
|
2012
|
+
if (typeof flags.get("public") === "string") {
|
|
2013
|
+
throw new Error("--public takes no value. Omit it for an owner-only link.");
|
|
2014
|
+
}
|
|
2015
|
+
const mode = flags.has("public") ? "public" : "login_required";
|
|
1845
2016
|
return { kind: "files", ...base, action: { kind: "share", fileId, mode, expiresInSeconds: parseDurationSeconds(expires), ...withProject } };
|
|
1846
2017
|
}
|
|
1847
2018
|
throw new Error(`Unknown files command: ${subcommand}`);
|
|
@@ -2293,6 +2464,13 @@ function bundleMetadataFromManifest(manifest, contentRoot) {
|
|
|
2293
2464
|
compatibilityFlags: ["nodejs_compat"],
|
|
2294
2465
|
};
|
|
2295
2466
|
}
|
|
2467
|
+
// bundleMetadataFromManifest returns the shape the API stores verbatim
|
|
2468
|
+
// (Record<string, unknown>), so narrow the module list here rather than loosening
|
|
2469
|
+
// that contract just to read it back.
|
|
2470
|
+
function bundleModulePaths(bundle) {
|
|
2471
|
+
const modules = bundle.modules;
|
|
2472
|
+
return Array.isArray(modules) ? modules.filter((entry) => typeof entry === "string") : [];
|
|
2473
|
+
}
|
|
2296
2474
|
function parseMemorySize(value) {
|
|
2297
2475
|
const match = value.trim().toLowerCase().match(/^(\d+)\s*(g|gb|m|mb)?$/);
|
|
2298
2476
|
if (!match) {
|
|
@@ -2815,6 +2993,14 @@ async function main() {
|
|
|
2815
2993
|
}
|
|
2816
2994
|
else {
|
|
2817
2995
|
console.log(`Deploy check passed: ${check.files} file(s), ${check.bytes} byte(s).`);
|
|
2996
|
+
if (check.serverCode) {
|
|
2997
|
+
// The compressed server-code figure, printed on success as well as on
|
|
2998
|
+
// warning/rejection, so the number the size limit is enforced on is always
|
|
2999
|
+
// visible — the hosted skill tells agents to read it from here.
|
|
3000
|
+
const { compressedBytes, moduleCount } = check.serverCode;
|
|
3001
|
+
const subject = moduleCount === 1 ? "entry" : `${moduleCount} server modules`;
|
|
3002
|
+
console.log(`Server code (${subject}): ${compressedBytes.toLocaleString("en-US")} bytes compressed of ${MAX_WORKER_ENTRY_COMPRESSED_BYTES.toLocaleString("en-US")} allowed.`);
|
|
3003
|
+
}
|
|
2818
3004
|
}
|
|
2819
3005
|
return;
|
|
2820
3006
|
}
|
|
@@ -2894,7 +3080,17 @@ async function main() {
|
|
|
2894
3080
|
confirmSlug: command.confirmSlug,
|
|
2895
3081
|
});
|
|
2896
3082
|
console.log(`Deleted ${deleted.slug}.`);
|
|
2897
|
-
console.log(`Removed ${deleted.workers} Worker(s), ${deleted.databases} database(s), ${deleted.files} file(s), ${deleted.customDomains} custom domain(s), ${deleted.
|
|
3083
|
+
console.log(`Removed ${deleted.workers} Worker(s), ${deleted.databases} database(s), ${deleted.files} file(s), ${deleted.customDomains} custom domain(s), ${deleted.containerImages ?? 0} container image(s), ${deleted.accessApps} access app(s), and ${deleted.serviceTokens} service token(s). Queued ${deleted.containerApplications ?? 0} container app(s) for teardown.`);
|
|
3084
|
+
// #722: a partial external-cleanup failure never fails the delete, and used to be
|
|
3085
|
+
// silently discarded here — visible only in the raw JSON, if a caller happened to
|
|
3086
|
+
// read it. Surfacing it is the whole reason a human running this command manually
|
|
3087
|
+
// can now notice what a CI script's own `finally` block cannot.
|
|
3088
|
+
if (deleted.errors && deleted.errors.length > 0) {
|
|
3089
|
+
console.log("Some cleanup steps did not complete:");
|
|
3090
|
+
for (const error of deleted.errors) {
|
|
3091
|
+
console.log(` - ${error}`);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
2898
3094
|
return;
|
|
2899
3095
|
}
|
|
2900
3096
|
if (command.kind === "latest") {
|
|
@@ -3102,7 +3298,14 @@ async function runFilesCommand(command) {
|
|
|
3102
3298
|
expiresInSeconds: command.action.expiresInSeconds,
|
|
3103
3299
|
});
|
|
3104
3300
|
console.log(result.url);
|
|
3105
|
-
|
|
3301
|
+
if (result.share.mode === "public") {
|
|
3302
|
+
// Say it plainly at the moment it becomes true: an agent pasting this into a
|
|
3303
|
+
// chat should not be the only place the word "public" appears (#637).
|
|
3304
|
+
console.log(`Share mode: PUBLIC — anyone with this URL can download the file, no sign-in. Expires ${result.share.expiresAt}.`);
|
|
3305
|
+
}
|
|
3306
|
+
else {
|
|
3307
|
+
console.log(`Share mode: owner-only — only you can open it, signed in. Expires ${result.share.expiresAt}.`);
|
|
3308
|
+
}
|
|
3106
3309
|
}
|
|
3107
3310
|
}
|
|
3108
3311
|
function printUsage() {
|
|
@@ -3126,7 +3329,7 @@ function printUsage() {
|
|
|
3126
3329
|
9pm files list [--project <site>]
|
|
3127
3330
|
9pm files put <local-path> [--path <remote-path>] [--project <site>]
|
|
3128
3331
|
9pm files get <file-id> [--output <path>] [--project <site>]
|
|
3129
|
-
9pm files share <file-id> [--expires 24h] [--
|
|
3332
|
+
9pm files share <file-id> [--expires 24h] [--public] [--project <site>]
|
|
3130
3333
|
9pm files delete <file-id> [--project <site>]
|
|
3131
3334
|
9pm deployments ls <app> [--json]
|
|
3132
3335
|
9pm deployments rm <deployment-id>
|
|
@@ -3151,6 +3354,15 @@ Environment:
|
|
|
3151
3354
|
${ALLOW_FILE_CREDENTIAL_STORE_ENV}
|
|
3152
3355
|
Allow plaintext credential file fallback when native storage is unavailable
|
|
3153
3356
|
|
|
3357
|
+
Size limits:
|
|
3358
|
+
Individual files are limited to ${formatMebibytes(MAX_STATIC_ASSET_FILE_BYTES)}.
|
|
3359
|
+
Worker runtime: server code is limited to ${formatMegabytes(MAX_WORKER_ENTRY_COMPRESSED_BYTES)} compressed (gzip) — the ${formatMegabytes(MAX_WORKER_SCRIPT_COMPRESSED_BYTES)} script limit
|
|
3360
|
+
less a small reserve for the generated wrapper. That budget covers the --entry module, or
|
|
3361
|
+
with --bundle every server module together, since they deploy as one script. Every other
|
|
3362
|
+
file in the directory is served from the asset store instead, so it does not count toward
|
|
3363
|
+
that limit — keep large images, fonts and datasets as sibling files rather than inlining
|
|
3364
|
+
them into the entry. Apps needing more server code than that should deploy as containers.
|
|
3365
|
+
|
|
3154
3366
|
Container deploys:
|
|
3155
3367
|
Requires Docker locally. The CLI builds linux/amd64, exports the image, uploads it through a short-lived session, then finalizes a queued deployment.
|
|
3156
3368
|
The app must listen on 0.0.0.0:$PORT or the selected --port. Use --health-path for a cheap readiness endpoint.
|