Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/actions/fern-generate/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ runs:
done

# One command, the same one a contributor runs locally. generate-sdk.ts owns
# spec resolution, the heap-tuned generator image and the custom runtime
# overlay; the TypeScript package additionally chains sdk-map generation.
# spec resolution and the custom runtime overlay; the TypeScript package
# additionally chains sdk-map generation.
- name: Generate ${{ inputs.language }} SDK
shell: bash
env:
Expand All @@ -82,9 +82,9 @@ runs:
fi
echo "==> $(find "${OUTPUT_PATH}" -type f | wc -l | tr -d ' ') files in ${OUTPUT_PATH}"

# Saved after generation so the tarball holds the heap-tuned wrapper that
# generate-sdk.ts retagged, not the stock image. actions/cache uploads the
# path at post-job.
# Saved after generation so the tarball holds exactly the images the run
# pulled. Fern's TypeScript generator runs natively and pulls none.
# actions/cache uploads the path at post-job.
- name: Save generator image
if: steps.image-cache.outputs.cache-hit != 'true'
shell: bash
Expand Down
13 changes: 7 additions & 6 deletions .github/workflows/sdk-typescript.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,17 @@
#
# The job runs the workspace's own target, `pnpm --filter @cloudflare/forge-sdk-ts
# generate`, which is the same command a contributor runs locally. Everything
# else -- spec resolution, the heap-tuned generator image, the custom runtime
# overlay, sdk-map -- lives in the packages, not here.
# else -- spec resolution, the custom runtime overlay, sdk-map -- lives in the
# packages, not here.
#
# Differences from the GitLab job, and why:
# - GitLab bootstrapped Node from a pinned tarball because its VM shell runner
# had none. GitHub-hosted runners ship Docker and Node tooling, so
# had none. GitHub-hosted runners ship Node tooling, so
# actions/setup-node + pnpm/action-setup replace ~35 lines of before_script.
# - GitLab sharded TypeScript generation into 3x2304 MB to fit an 8 GB runner.
# ubuntu-latest has 16 GB, so a single heap-tuned generator image is enough
# and the shard/merge machinery is dropped.
# - GitLab sharded TypeScript generation into 3x2304 MB Docker containers to
# fit an 8 GB runner. Fern's TypeScript generator now runs natively in one
# process with an 8 GB heap on ubuntu-latest (16 GB), so the shard/merge
# machinery is gone.
name: SDK / TypeScript

# GitHub Actions does not support YAML anchors, so the path filter is repeated.
Expand Down
16 changes: 11 additions & 5 deletions packages/cloudflare-fern-config/fern/generators.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@
# - The other eight languages generate one folder per language under
# packages/cloudflare-forge-sdk-{lang}.
#
# The TypeScript generator needs a larger heap than the official image ships
# with: Cloudflare's IR is ~242 MB and node exits 133 on it with the default
# max-old-space-size. generate-sdk.ts retags a heap-tuned wrapper over the
# official fernapi/* name before invoking Fern.
# TypeScript runs Cloudflare's fork of Fern's TypeScript generator
# (fernapi/fern-typescript-sdk 3.88.3), vendored and run natively via
# `local-command` instead of the Docker image; see vendor/codegen-cli/README.md
# at the repo root. The command runs with this directory as cwd. Cloudflare's
# IR is ~242 MB, so node needs a larger heap than its default. The other
# languages still run the official fernapi/* images.
api:
specs:
- openapi: ./openapi.json
Expand All @@ -40,7 +42,11 @@ groups:
typescript-sdk:
generators:
- name: fernapi/fern-typescript-sdk
version: 3.80.1
version: 3.88.3
local-command:
- node
- --max-old-space-size=8192
- ../node_modules/@cloudflare/codegen-typescript-sdk/cli.cjs
output:
location: local-file-system
path: ../../cloudflare-forge-sdk-ts/src/_generated
Expand Down
3 changes: 2 additions & 1 deletion packages/cloudflare-fern-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
"generate": "tsx scripts/generate-sdk.ts"
},
"devDependencies": {
"@cloudflare/codegen-typescript-sdk": "file:../../vendor/codegen-cli/cloudflare-codegen-typescript-sdk-3.88.3.tgz",
"@cloudflare/forge": "workspace:*",
"@types/node": "26.0.1",
"fern-api": "5.112.0",
"fern-api": "file:../../vendor/codegen-cli/fern-api-5.112.0.tgz",
"tsx": "4.22.4",
"typescript": "6.0.3"
},
Expand Down
176 changes: 35 additions & 141 deletions packages/cloudflare-fern-config/scripts/generate-sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,15 @@
// A language maps to the "<language>-sdk" group in fern/generators.yml.
//
// Everything a generation run needs lives here, so CI and a laptop run the same
// command: spec resolution, the heap-tuned generator image, the Fern invocation,
// and the TypeScript custom-runtime overlay.
// command: spec resolution, the Fern invocation, and the TypeScript
// custom-runtime overlay.
//
// Requires a Docker daemon -- Fern generators are distributed as container
// images and this uses `fern generate --local`.
// TypeScript runs the vendored fork of Fern's TypeScript generator natively
// (local-command in generators.yml). The other languages need a Docker daemon:
// their generators are distributed as container images and this uses
// `fern generate --local`.
import { execFileSync } from 'node:child_process';
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
statSync,
writeFileSync,
} from 'node:fs';
import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { applyFernCompatibilityFixes } from '@cloudflare/forge/fern-openapi-compat';
Expand All @@ -34,10 +27,6 @@ const FERN_DIR = join(PKG_ROOT, 'fern');
const GENERATORS = join(FERN_DIR, 'generators.yml');
const SPEC = join(FERN_DIR, 'openapi.json');

// Heap for the TypeScript generator container. Cloudflare's IR is ~242 MB and
// the official image exits 133 partway through generation with node's default.
const TYPESCRIPT_HEAP_MB = process.env['FERN_TYPESCRIPT_HEAP_MB'] ?? '8192';

const languages = process.argv.slice(2);
if (languages.length === 0) {
languages.push('typescript');
Expand All @@ -57,12 +46,21 @@ function run(command: string, args: string[], options: { cwd: string; env?: Node
}

// ------------------------------------------------------------------ preflight
try {
execFileSync('docker', ['info'], { stdio: 'ignore' });
} catch {
fail('a running Docker daemon is required (fern generate --local runs generators as containers)');
if (languages.some((language) => language !== 'typescript')) {
try {
execFileSync('docker', ['info'], { stdio: 'ignore' });
} catch {
fail('a running Docker daemon is required (fern generate --local runs these generators as containers)');
}
}

const generatorGroups = new Set(
readFileSync(GENERATORS, 'utf8')
.split('\n')
.map((line) => /^ {2}(\S+-sdk):$/.exec(line)?.[1])
.filter((group) => group !== undefined),
);

// ----------------------------------------------------------------------- spec
const FORGE_OPENAPI_TAG = /^openapi\.v\d{8}\.\d+$/;

Expand Down Expand Up @@ -125,150 +123,46 @@ if (fernCompatibilityFixCount > 0) {
console.log(`==> Applied ${fernCompatibilityFixCount} Fern OpenAPI compatibility repair(s)`);
}

// ---------------------------------------------------------- generator image --
// Read the pinned image for a group straight out of generators.yml so the tag can
// never drift from the generator Fern is about to run. Deliberately a line scan
// rather than a YAML dependency: the shape is a two-line literal.
function generatorImage(language: string): string | undefined {
const lines = readFileSync(GENERATORS, 'utf8').split('\n');
let inGroup = false;
let name: string | undefined;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed === `${language}-sdk:`) {
inGroup = true;
continue;
}
if (!inGroup) continue;
const nameMatch = /^-\s+name:\s*(\S+)$/.exec(trimmed);
if (nameMatch) name = nameMatch[1];
const versionMatch = /^version:\s*(\S+)$/.exec(trimmed);
if (versionMatch && name !== undefined) return `${name}:${versionMatch[1]}`;
}
return undefined;
}

// Fern always runs the official image name, so a heap-tuned wrapper has to be
// retagged over it. The label makes this idempotent across runs.
function ensureHeapTunedImage(image: string, heapMb: string) {
let current = '';
try {
current = execFileSync(
'docker',
['image', 'inspect', image, '--format', '{{index .Config.Labels "forge.heap-mb"}}'],
{
encoding: 'utf8',
},
).trim();
} catch {
current = '';
}
if (current === heapMb) {
console.log(`==> ${image} already heap-tuned (${heapMb} MB)`);
return;
}
console.log(`==> Building heap-tuned ${image} (${heapMb} MB)`);
run('docker', ['pull', image]);
// Only the heap is tuned: node rejects --stack-size in NODE_OPTIONS, and
// RLIMIT_STACK is 8 MB in these images with no way to pass --ulimit.
const dockerfile = `FROM ${image}\nENV NODE_OPTIONS="--max-old-space-size=${heapMb}"\n`;
execFileSync('docker', ['build', '--tag', image, '--label', `forge.heap-mb=${heapMb}`, '-'], {
input: dockerfile,
stdio: ['pipe', 'inherit', 'inherit'],
});
}

// ------------------------------------------------------------------ generate --
const groupArgs: string[] = [];
for (const language of languages) {
const image = generatorImage(language);
if (image === undefined) {
if (!generatorGroups.has(`${language}-sdk`)) {
fail(`no '${language}-sdk' group in ${GENERATORS}`);
}
if (language === 'typescript') {
ensureHeapTunedImage(image, TYPESCRIPT_HEAP_MB);
}
groupArgs.push('--group', `${language}-sdk`);
}

const sdkTs = join(REPO_ROOT, 'packages', 'cloudflare-forge-sdk-ts');
const generated = join(sdkTs, 'src', '_generated');
if (languages.includes('typescript')) {
// The custom runtime (and its .fernignore) is re-installed below, so start from
// an empty dir: with a .fernignore present Fern copies output through a temp
// git repo, which costs ~45s on this SDK.
rmSync(generated, { recursive: true, force: true });
}

// cwd must be at or below the package root so the CLI discovers fern/.
run(
join(PKG_ROOT, 'node_modules', '.bin', 'fern'),
['generate', ...groupArgs, '--local', '--no-prompt', '--force', '--log-level', 'info'],
{
cwd: PKG_ROOT,
// Heap for the Fern CLI itself (validation + IR), separate from the container.
// Heap for the Fern CLI itself (validation + IR); the TypeScript generator
// sets its own in generators.yml.
env: { ...process.env, NODE_OPTIONS: process.env['NODE_OPTIONS'] ?? '--max-old-space-size=4096' },
},
);

// ------------------------------------------------------- typescript post-step --
if (languages.includes('typescript')) {
const sdkTs = join(REPO_ROOT, 'packages', 'cloudflare-forge-sdk-ts');
const generated = join(sdkTs, 'src', '_generated');

// Fern can exit 0 having produced nothing useful when a generator crashes
// inside the container, so assert on a real entrypoint before going further.
// Fern can exit 0 having produced nothing useful when a generator crashes,
// so assert on a real entrypoint before going further.
if (!isNonEmptyFile(join(generated, 'index.ts'))) {
fail(`Fern exited 0 but ${join(generated, 'index.ts')} is missing or empty`);
}

// Fern 3.80.1 emits invalid dotted/subtraction expressions for multipart
// fields whose wire names are not identifiers. Use bracket access so the
// request type and serializer retain the exact wire name.
const openApi = JSON.parse(readFileSync(SPEC, 'utf8')) as Record<string, unknown>;
const propertyNames = new Set<string>();
for (const pathItem of Object.values((openApi['paths'] as Record<string, unknown> | undefined) ?? {})) {
if (pathItem === null || typeof pathItem !== 'object') continue;
for (const operation of Object.values(pathItem as Record<string, unknown>)) {
if (operation === null || typeof operation !== 'object') continue;
const requestBody = (operation as Record<string, unknown>)['requestBody'];
if (requestBody === null || typeof requestBody !== 'object') continue;
const content = (requestBody as Record<string, unknown>)['content'];
if (content === null || typeof content !== 'object') continue;
const multipart = (content as Record<string, unknown>)['multipart/form-data'];
if (multipart === null || typeof multipart !== 'object') continue;
const schema = (multipart as Record<string, unknown>)['schema'];
if (schema === null || typeof schema !== 'object') continue;
const properties = (schema as Record<string, unknown>)['properties'];
if (properties === null || typeof properties !== 'object') continue;
for (const wireName of Object.keys(properties as Record<string, unknown>)) {
if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(wireName)) propertyNames.add(wireName);
}
}
}

let correctedAccessors = 0;
const correctFile = (path: string): void => {
for (const entry of readdirSync(path, { withFileTypes: true })) {
const entryPath = join(path, entry.name);
if (entry.isDirectory()) {
correctFile(entryPath);
} else if (entry.name.endsWith('.ts')) {
const source = readFileSync(entryPath, 'utf8');
let corrected = source;
for (const wireName of propertyNames) {
const emittedAccess = [...wireName]
.map((character) => (character === '-' ? '\\s*-\\s*' : character.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
.join('');
corrected = corrected.replace(
new RegExp(`request\\.${emittedAccess}`, 'g'),
`request[${JSON.stringify(wireName)}]`,
);
}
if (corrected !== source) {
correctedAccessors += 1;
writeFileSync(entryPath, corrected);
}
}
}
};
correctFile(generated);
console.log(`==> Corrected multipart property access in ${correctedAccessors} generated file(s)`);

// allowCustomFetcher expects the Cloudflare envelope unwrap and URL join
// overrides layered over the generated core. The .fernignore travels with them
// so a later regeneration preserves them.
// overrides layered over the generated core.
console.log('==> Installing TypeScript custom runtime');
cpSync(join(sdkTs, 'custom'), generated, { recursive: true });
}
Expand Down
4 changes: 2 additions & 2 deletions packages/cloudflare-forge-sdk-ts/custom/.fernignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Cloudflare custom runtime. Installed after each generate (generate-fern.ts wipes
# the output dir first). Sources: packages/cloudflare-forge-sdk-ts/custom/.
# Cloudflare custom runtime. Installed after each generate (cloudflare-fern-config's
# generate-sdk.ts wipes the output dir first). Sources: packages/cloudflare-forge-sdk-ts/custom/.
# - getResponseBody / unwrapCloudflareEnvelope: envelope unwrap for payload-first types (fix-26).
# - url/join: preserve query/fragment suffixes so passthrough callers can pass query params in the path.
core/fetcher/getResponseBody.ts
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare-forge-sdk-ts/custom/core/url/join.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Cloudflare custom runtime: query-string / fragment preservation in join().
//
// Divergence from stock Fern TS 3.80.1 `core/url/join.ts`:
// Divergence from stock Fern TS 3.88.3 `core/url/join.ts`:
// - A path segment carrying a `?query` or `#fragment` suffix has that suffix
// peeled off and reattached to the URL's search/hash, instead of being folded
// into the pathname.
Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare-forge-sdk-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@cloudflare/fern-config": "workspace:*",
"@cloudflare/forge": "workspace:*",
"@types/node": "26.0.1",
"fern-api": "5.112.0",
"fern-api": "file:../../vendor/codegen-cli/fern-api-5.112.0.tgz",
"tsx": "4.22.4",
"typescript": "6.0.3"
},
Expand Down
Loading
Loading