diff --git a/.changeset/fifty-cobras-wish.md b/.changeset/fifty-cobras-wish.md new file mode 100644 index 00000000..0c98fc94 --- /dev/null +++ b/.changeset/fifty-cobras-wish.md @@ -0,0 +1,5 @@ +--- +'sv': minor +--- + +feat(cloudflare): able to fully setup cloudflare adapter for workers/pages diff --git a/documentation/docs/30-add-ons/45-sveltekit-adapter.md b/documentation/docs/30-add-ons/45-sveltekit-adapter.md index 2df35754..cf864fbf 100644 --- a/documentation/docs/30-add-ons/45-sveltekit-adapter.md +++ b/documentation/docs/30-add-ons/45-sveltekit-adapter.md @@ -30,3 +30,11 @@ Which SvelteKit adapter to use: ```sh npx sv add sveltekit-adapter="adapter:node" ``` + +### cloudflare target + +Whether to deploy to Cloudflare Workers or Pages. Only available for `cloudflare` adapter. + +```sh +npx sv add sveltekit-adapter="adapter:cloudflare+cfTarget:workers" +``` diff --git a/packages/sv/lib/addons/_tests/sveltekit-adapter/test.ts b/packages/sv/lib/addons/_tests/sveltekit-adapter/test.ts index b11a8fd7..f5ea1e45 100644 --- a/packages/sv/lib/addons/_tests/sveltekit-adapter/test.ts +++ b/packages/sv/lib/addons/_tests/sveltekit-adapter/test.ts @@ -5,36 +5,42 @@ import sveltekitAdapter from '../../sveltekit-adapter/index.ts'; import { setupTest } from '../_setup/suite.ts'; const addonId = sveltekitAdapter.id; -const { test, testCases, prepareServer } = setupTest( +const { test, testCases } = setupTest( { [addonId]: sveltekitAdapter }, { kinds: [ { type: 'node', options: { [addonId]: { adapter: 'node' } } }, - { type: 'auto', options: { [addonId]: { adapter: 'auto' } } } + { type: 'auto', options: { [addonId]: { adapter: 'auto' } } }, + { + type: 'cloudflare-workers', + options: { [addonId]: { adapter: 'cloudflare', cfTarget: 'workers' } } + }, + { + type: 'cloudflare-pages', + options: { [addonId]: { adapter: 'cloudflare', cfTarget: 'pages' } } + } ], - filter: (addonTestCase) => addonTestCase.variant.includes('kit') + filter: (addonTestCase) => addonTestCase.variant.includes('kit'), + browser: false } ); -test.concurrent.for(testCases)( - 'adapter $kind.type $variant', - async (testCase, { page, ...ctx }) => { - const cwd = ctx.cwd(testCase); +test.concurrent.for(testCases)('adapter $kind.type $variant', async (testCase, { ...ctx }) => { + const cwd = ctx.cwd(testCase); - const { close } = await prepareServer({ cwd, page }); - // kill server process when we're done - ctx.onTestFinished(async () => await close()); - - if (testCase.kind.type === 'node') { - expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).not.toMatch('adapter-auto'); - expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).not.toMatch( - 'adapter-auto only supports some environments' - ); - } else if (testCase.kind.type === 'auto') { - expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).toMatch('adapter-auto'); - expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).toMatch( - 'adapter-auto only supports some environments' - ); - } + if (testCase.kind.type === 'node') { + expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).not.toMatch('adapter-auto'); + expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).not.toMatch( + 'adapter-auto only supports some environments' + ); + } else if (testCase.kind.type === 'auto') { + expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).toMatch('adapter-auto'); + expect(await readFile(join(cwd, 'svelte.config.js'), 'utf8')).toMatch( + 'adapter-auto only supports some environments' + ); + } else if (testCase.kind.type === 'cloudflare-workers') { + expect(await readFile(join(cwd, 'wrangler.jsonc'), 'utf8')).toMatch('ASSETS'); + } else if (testCase.kind.type === 'cloudflare-pages') { + expect(await readFile(join(cwd, 'wrangler.jsonc'), 'utf8')).toMatch('pages_build_output_dir'); } -); +}); diff --git a/packages/sv/lib/addons/sveltekit-adapter/index.ts b/packages/sv/lib/addons/sveltekit-adapter/index.ts index 7f82302b..b5630edc 100644 --- a/packages/sv/lib/addons/sveltekit-adapter/index.ts +++ b/packages/sv/lib/addons/sveltekit-adapter/index.ts @@ -1,6 +1,9 @@ import { defineAddon, defineAddonOptions } from '../../core/index.ts'; -import { exports, functions, imports, object } from '../../core/tooling/js/index.ts'; -import { parseJson, parseScript } from '../../core/tooling/parsers.ts'; +import { exports, functions, imports, object, type AstTypes } from '../../core/tooling/js/index.ts'; +import { parseJson, parseScript, parseToml } from '../../core/tooling/parsers.ts'; +import { fileExists, readFile } from '../../cli/add/utils.ts'; +import { resolveCommand } from 'package-manager-detector'; +import * as js from '../../core/tooling/js/index.ts'; const adapters = [ { id: 'auto', package: '@sveltejs/adapter-auto', version: '^7.0.0' }, @@ -18,6 +21,16 @@ const options = defineAddonOptions() default: 'auto', options: adapters.map((p) => ({ value: p.id, label: p.id, hint: p.package })) }) + .add('cfTarget', { + condition: (options) => options.adapter === 'cloudflare', + type: 'select', + question: 'Are you deploying to Workers (assets) or Pages?', + default: 'workers', + options: [ + { value: 'workers', label: 'Workers', hint: 'Recommended way to deploy to Cloudflare' }, + { value: 'pages', label: 'Pages' } + ] + }) .build(); export default defineAddon({ @@ -29,7 +42,7 @@ export default defineAddon({ setup: ({ kit, unsupported }) => { if (!kit) unsupported('Requires SvelteKit'); }, - run: ({ sv, options, files }) => { + run: ({ sv, options, files, cwd, packageManager, typescript }) => { const adapter = adapters.find((a) => a.id === options.adapter)!; // removes previously installed adapters @@ -43,6 +56,15 @@ export default defineAddon({ } } + // in sk 3, we will keep "preview": "vite preview" like any other adapter + if (options.adapter === 'cloudflare') { + if (options.cfTarget === 'workers') { + data.scripts.preview = 'wrangler dev .svelte-kit/cloudflare/_worker.js'; + } else if (options.cfTarget === 'pages') { + data.scripts.preview = 'wrangler pages dev .svelte-kit/cloudflare'; + } + } + return generateCode(); }); @@ -99,5 +121,135 @@ export default defineAddon({ return generateCode(); }); + + if (adapter.package === '@sveltejs/adapter-cloudflare') { + sv.devDependency('wrangler', '^4.56.0'); + + // default to jsonc + const configFormat = fileExists(cwd, 'wrangler.toml') ? 'toml' : 'jsonc'; + + // Setup Cloudlfare workers/pages config + sv.file(`wrangler.${configFormat}`, (content) => { + const { data, generateCode } = + configFormat === 'jsonc' ? parseJson(content) : parseToml(content); + + if (configFormat === 'jsonc') { + data.$schema ??= './node_modules/wrangler/config-schema.json'; + } + + if (!data.name) { + const pkg = parseJson(readFile(cwd, files.package)); + data.name = pkg.data.name; + } + + data.compatibility_date ??= new Date().toISOString().split('T')[0]; + data.compatibility_flags ??= []; + + if ( + !data.compatibility_flags.includes('nodejs_compat') && + !data.compatibility_flags.includes('nodejs_als') + ) { + data.compatibility_flags.push('nodejs_als'); + } + + switch (options.cfTarget) { + case 'workers': + data.main = '.svelte-kit/cloudflare/_worker.js'; + data.assets ??= {}; + data.assets.binding = 'ASSETS'; + data.assets.directory = '.svelte-kit/cloudflare'; + data.workers_dev = true; + data.preview_urls = true; + break; + + case 'pages': + data.pages_build_output_dir = '.svelte-kit/cloudflare'; + break; + } + + return generateCode(); + }); + + const jsconfig = fileExists(cwd, 'jsconfig.json'); + const typeChecked = typescript || jsconfig; + + if (typeChecked) { + // Ignore generated Cloudflare Types + sv.file(files.gitignore, (content) => { + return content.includes('.wrangler') && content.includes('worker-configuration.d.ts') + ? content + : `${content.trimEnd()}\n\n# Cloudflare Types\n/worker-configuration.d.ts`; + }); + + // Setup wrangler types command + sv.file(files.package, (content) => { + const { data, generateCode } = parseJson(content); + + data.scripts ??= {}; + data.scripts.types = 'wrangler types'; + const { command, args } = resolveCommand(packageManager, 'run', ['types'])!; + data.scripts.prepare = data.scripts.prepare + ? `${command} ${args.join(' ')} && ${data.scripts.prepare}` + : `${command} ${args.join(' ')}`; + + return generateCode(); + }); + + // Add Cloudflare generated types to tsconfig + sv.file(`${jsconfig ? 'jsconfig' : 'tsconfig'}.json`, (content) => { + const { data, generateCode } = parseJson(content); + + data.compilerOptions ??= {}; + data.compilerOptions.types ??= []; + data.compilerOptions.types.push('worker-configuration.d.ts'); + + return generateCode(); + }); + + sv.file('src/app.d.ts', (content) => { + const { ast, generateCode } = parseScript(content); + + const platform = js.kit.addGlobalAppInterface(ast, { name: 'Platform' }); + if (!platform) { + throw new Error('Failed detecting `platform` interface in `src/app.d.ts`'); + } + + platform.body.body.push( + createCloudflarePlatformType('env', 'Env'), + createCloudflarePlatformType('ctx', 'ExecutionContext'), + createCloudflarePlatformType('caches', 'CacheStorage'), + createCloudflarePlatformType('cf', 'IncomingRequestCfProperties', true) + ); + + return generateCode(); + }); + } + } } }); + +function createCloudflarePlatformType( + name: string, + value: string, + optional = false +): AstTypes.TSInterfaceBody['body'][number] { + return { + type: 'TSPropertySignature', + key: { + type: 'Identifier', + name + }, + computed: false, + optional, + typeAnnotation: { + type: 'TSTypeAnnotation', + typeAnnotation: { + type: 'TSTypeReference', + typeName: { + type: 'Identifier', + name: value + } + } + } + }; +} diff --git a/packages/sv/lib/core/tooling/index.ts b/packages/sv/lib/core/tooling/index.ts index 5ef8d559..d42f84e1 100644 --- a/packages/sv/lib/core/tooling/index.ts +++ b/packages/sv/lib/core/tooling/index.ts @@ -8,6 +8,7 @@ import { tsPlugin } from '@sveltejs/acorn-typescript'; import { parse as svelteParse, type AST as SvelteAst, print as sveltePrint } from 'svelte/compiler'; import * as yaml from 'yaml'; import type { BaseNode } from 'estree'; +import * as toml from 'smol-toml'; export { // ast walker @@ -288,3 +289,11 @@ export function parseSvelte(content: string): SvelteAst.Root { export function serializeSvelte(ast: SvelteAst.SvelteNode): string { return sveltePrint(ast).code; } + +export function parseToml(content: string): toml.TomlTable { + return toml.parse(content); +} + +export function serializeToml(data: toml.TomlTable): string { + return toml.stringify(data); +} diff --git a/packages/sv/lib/core/tooling/js/ts-estree.ts b/packages/sv/lib/core/tooling/js/ts-estree.ts index 49ffba8a..45d0473a 100644 --- a/packages/sv/lib/core/tooling/js/ts-estree.ts +++ b/packages/sv/lib/core/tooling/js/ts-estree.ts @@ -46,6 +46,7 @@ declare module 'estree' { type: 'TSPropertySignature'; computed: boolean; key: Identifier; + optional?: boolean; typeAnnotation: TSTypeAnnotation; } interface TSProgram extends Omit { diff --git a/packages/sv/lib/core/tooling/parsers.ts b/packages/sv/lib/core/tooling/parsers.ts index 4b763f8a..2dc8c597 100644 --- a/packages/sv/lib/core/tooling/parsers.ts +++ b/packages/sv/lib/core/tooling/parsers.ts @@ -1,3 +1,4 @@ +import type { TomlTable } from 'smol-toml'; import * as utils from './index.ts'; type ParseBase = { @@ -57,3 +58,13 @@ export function parseSvelte(source: string): { ast: utils.SvelteAst.Root } & Par generateCode }; } + +export function parseToml(source: string): { data: TomlTable } & ParseBase { + const data = utils.parseToml(source); + + return { + data, + source, + generateCode: () => utils.serializeToml(data) + }; +} diff --git a/packages/sv/package.json b/packages/sv/package.json index 8a67dc83..39943a46 100644 --- a/packages/sv/package.json +++ b/packages/sv/package.json @@ -53,6 +53,7 @@ "picocolors": "^1.1.1", "ps-tree": "^1.2.0", "silver-fleece": "^1.2.1", + "smol-toml": "^1.5.2", "sucrase": "^3.35.1", "svelte": "^5.45.10", "tiny-glob": "^0.2.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02cc36dd..f2313be9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -184,6 +184,9 @@ importers: silver-fleece: specifier: ^1.2.1 version: 1.2.1 + smol-toml: + specifier: ^1.5.2 + version: 1.5.2 sucrase: specifier: ^3.35.1 version: 3.35.1 @@ -1872,6 +1875,10 @@ packages: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} + smol-toml@1.5.2: + resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==} + engines: {node: '>= 18'} + sort-object-keys@2.0.1: resolution: {integrity: sha512-R89fO+z3x7hiKPXX5P0qim+ge6Y60AjtlW+QQpRozrrNcR1lw9Pkpm5MLB56HoNvdcLHL4wbpq16OcvGpEDJIg==} @@ -3783,6 +3790,8 @@ snapshots: slash@3.0.0: {} + smol-toml@1.5.2: {} + sort-object-keys@2.0.1: {} sort-package-json@3.5.0: