Skip to content

feat: add support for read in edge environments #13859

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Jul 17, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
778fc49
feat: add support for server read in cloudflare adapter
vnphanquang Jun 5, 2025
9a525d6
fix: use txt instead of jpg for read test in cloudflare adapter
vnphanquang Jun 6, 2025
de885af
refactor: extract reusable ('@sveltejs/kit/adapter').streamFileConten…
vnphanquang Jun 6, 2025
d13673f
Merge branch 'main' into pr/vnphanquang/13859
eltigerchino Jun 24, 2025
126ae02
reword changesets
eltigerchino Jun 24, 2025
2f64527
clean up test
eltigerchino Jun 24, 2025
ad6a5ee
workers and pages are essentially the same, so we only need one
eltigerchino Jun 24, 2025
594b20f
implement for vercel and netlify
eltigerchino Jun 24, 2025
bc99bbd
lockfile
eltigerchino Jun 24, 2025
a32a03d
format
eltigerchino Jun 24, 2025
d11861a
remove rollup upgrades
eltigerchino Jun 25, 2025
ce56718
Merge branch 'main' into pr/vnphanquang/13859
eltigerchino Jun 30, 2025
9f4dc20
Merge branch 'main' into pr/vnphanquang/13859
eltigerchino Jul 11, 2025
21ca529
Merge branch 'main' into pr/vnphanquang/13859
eltigerchino Jul 14, 2025
f93ad3d
clear output in plugin instead of npm script
eltigerchino Jul 15, 2025
03ddb3f
tweak
dummdidumm Jul 15, 2025
4d13219
avoid new SvelteKit export by handling promises in internal read impl…
dummdidumm Jul 15, 2025
07d8f28
oops
dummdidumm Jul 15, 2025
c30baad
Merge branch 'main' into pr/vnphanquang/13859
dummdidumm Jul 15, 2025
47855d1
annoying temporary workaround that will break the version.spec unit test
dummdidumm Jul 15, 2025
72951a8
fix
dummdidumm Jul 15, 2025
dcfc5b1
ignore deno.lock
eltigerchino Jul 16, 2025
adfcd0a
split version string once
eltigerchino Jul 16, 2025
0fc238f
Merge branch 'main' into pr/vnphanquang/13859
eltigerchino Jul 16, 2025
0a1b4cc
format
eltigerchino Jul 16, 2025
3f0328e
generate types
eltigerchino Jul 16, 2025
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
6 changes: 6 additions & 0 deletions .changeset/moody-birds-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/adapter-netlify': minor
'@sveltejs/adapter-vercel': minor
---

feat: add support for `read` imported from `$app/server` in edge functions
5 changes: 5 additions & 0 deletions .changeset/old-moons-lick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/adapter-cloudflare': minor
---

feat: add support for `read` imported from `$app/server`
5 changes: 5 additions & 0 deletions .changeset/weak-pillows-doubt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: support asynchronous `read` implementations from adapters
1 change: 1 addition & 0 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export default [
},
ignores: [
'packages/adapter-cloudflare/test/apps/**/*',
'packages/adapter-netlify/test/apps/**/*',
'packages/adapter-node/rollup.config.js',
'packages/adapter-node/tests/smoke.spec_disabled.js',
'packages/adapter-static/test/apps/**/*',
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@
},
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"esbuild",
"netlify-cli",
"rolldown",
"sharp",
"svelte-preprocess",
"unix-dgram",
"workerd"
]
}
Expand Down
18 changes: 17 additions & 1 deletion packages/adapter-cloudflare/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { VERSION } from '@sveltejs/kit';
import { copyFileSync, existsSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { is_building_for_cloudflare_pages } from './utils.js';
import { getPlatformProxy, unstable_readConfig } from 'wrangler';

const name = '@sveltejs/adapter-cloudflare';
const [kit_major, kit_minor] = VERSION.split('.');

/** @type {import('./index.js').default} */
export default function (options = {}) {
return {
name: '@sveltejs/adapter-cloudflare',
name,
async adapt(builder) {
if (existsSync('_routes.json')) {
throw new Error(
Expand Down Expand Up @@ -162,6 +166,18 @@ export default function (options = {}) {
return prerender ? emulated.prerender_platform : emulated.platform;
}
};
},
supports: {
read: ({ route }) => {
// TODO bump peer dep in next adapter major to simplify this
if (kit_major === '2' && kit_minor < '25') {
throw new Error(
`${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` when using SvelteKit < 2.25.0`
);
}

return true;
}
}
};
}
Expand Down
6 changes: 3 additions & 3 deletions packages/adapter-cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@
"ambient.d.ts"
],
"scripts": {
"build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --format=esm",
"build": "esbuild src/worker.js --bundle --outfile=files/worker.js --external:SERVER --external:MANIFEST --external:cloudflare:workers --format=esm",
"lint": "prettier --check .",
"format": "pnpm lint --write",
"check": "tsc --skipLibCheck",
"prepublishOnly": "pnpm build",
"test:unit": "vitest run",
"test:e2e": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test",
"test": "pnpm test:unit && pnpm test:e2e"
"test": "pnpm test:unit && pnpm test:e2e",
"prepublishOnly": "pnpm build"
},
"dependencies": {
"@cloudflare/workers-types": "^4.20250507.0",
Expand Down
30 changes: 26 additions & 4 deletions packages/adapter-cloudflare/src/worker.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Server } from 'SERVER';
import { manifest, prerendered, base_path } from 'MANIFEST';
import { env } from 'cloudflare:workers';
import * as Cache from 'worktop/cfw.cache';

const server = new Server(manifest);
Expand All @@ -9,6 +10,27 @@ const app_path = `/${manifest.appPath}`;
const immutable = `${app_path}/immutable/`;
const version_file = `${app_path}/version.json`;

/**
* We don't know the origin until we receive a request, but
* that's guaranteed to happen before we call `read`
* @type {string}
*/
let origin;

const initialized = server.init({
// @ts-expect-error env contains environment variables and bindings
env,
read: async (file) => {
const response = await /** @type {{ ASSETS: { fetch: typeof fetch } }} */ (env).ASSETS.fetch(
`${origin}/${file}`
);
if (!response.ok) {
throw new Error(`Failed to fetch ${file}: ${response.status} ${response.statusText}`);
}
return response.body;
}
});

export default {
/**
* @param {Request} req
Expand All @@ -17,10 +39,10 @@ export default {
* @returns {Promise<Response>}
*/
async fetch(req, env, ctx) {
await server.init({
// @ts-expect-error env contains environment variables and bindings
env
});
if (!origin) {
origin = new URL(req.url).origin;
await initialized;
}

// skip cache if "cache-control: no-cache" in request
let pragma = req.headers.get('cache-control') || '';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { read } from '$app/server';
import file from './file.txt?url';

export function GET() {
return read(file);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Hello! This file is read by `read` from `$app/server`.
11 changes: 11 additions & 0 deletions packages/adapter-cloudflare/test/apps/workers/test/test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { expect, test } from '@playwright/test';

const __dirname = path.dirname(fileURLToPath(import.meta.url));

test('worker', async ({ page }) => {
await page.goto('/');
await expect(page.locator('h1')).toContainText('Sum: 3');
Expand All @@ -9,3 +14,9 @@ test('ctx', async ({ request }) => {
const res = await request.get('/ctx');
expect(await res.text()).toBe('ctx works');
});

test('read from $app/server works', async ({ request }) => {
const content = fs.readFileSync(path.resolve(__dirname, '../src/routes/read/file.txt'), 'utf-8');
const response = await request.get('/read');
expect(await response.text()).toBe(content);
});
37 changes: 10 additions & 27 deletions packages/adapter-netlify/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import { builtinModules } from 'node:module';
import process from 'node:process';
import esbuild from 'esbuild';
import toml from '@iarna/toml';
import { VERSION } from '@sveltejs/kit';

const [kit_major, kit_minor] = VERSION.split('.');

/**
* @typedef {{
Expand All @@ -13,26 +16,6 @@ import toml from '@iarna/toml';
* } & toml.JsonMap} NetlifyConfig
*/

/**
* TODO(serhalp) Replace this custom type with an import from `@netlify/edge-functions`,
* once that type is fixed to include `excludedPath` and `function`.
* @typedef {{
* functions: Array<
* | {
* function: string;
* path: string;
* excludedPath?: string | string[];
* }
* | {
* function: string;
* pattern: string;
* excludedPattern?: string | string[];
* }
* >;
* version: 1;
* }} HandlerManifest
*/

const name = '@sveltejs/adapter-netlify';
const files = fileURLToPath(new URL('./files', import.meta.url).href);

Expand Down Expand Up @@ -114,11 +97,11 @@ export default function ({ split = false, edge = edge_set_in_env_var } = {}) {
},

supports: {
// reading from the filesystem only works in serverless functions
read: ({ route }) => {
if (edge) {
// TODO bump peer dep in next adapter major to simplify this
if (edge && kit_major === '2' && kit_minor < '25') {
throw new Error(
`${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` when using edge functions`
`${name}: Cannot use \`read\` from \`$app/server\` in route \`${route.id}\` when using edge functions and SvelteKit < 2.25.0`
);
}

Expand Down Expand Up @@ -161,7 +144,7 @@ async function generate_edge_functions({ builder }) {
const path = '/*';
// We only need to specify paths without the trailing slash because
// Netlify will handle the optional trailing slash for us
const excludedPath = [
const excluded = [
// Contains static files
`/${builder.getAppPath()}/*`,
...builder.prerendered.paths,
Expand All @@ -179,13 +162,13 @@ async function generate_edge_functions({ builder }) {
'/.netlify/*'
];

/** @type {HandlerManifest} */
/** @type {import('@netlify/edge-functions').Manifest} */
const edge_manifest = {
functions: [
{
function: 'render',
path,
excludedPath
excludedPath: /** @type {`/${string}`[]} */ (excluded)
}
],
version: 1
Expand Down Expand Up @@ -232,7 +215,7 @@ function generate_lambda_functions({ builder, publish, split }) {
'0SERVER': './server/index.js' // digit prefix prevents CJS build from using this as a variable name, which would also get replaced
};

builder.copy(`${files}/esm`, '.netlify', { replace });
builder.copy(files, '.netlify', { replace });

// Configuring the function to use ESM as the output format.
const fn_config = JSON.stringify({ config: { nodeModuleFormat: 'esm' }, version: 1 });
Expand Down
9 changes: 6 additions & 3 deletions packages/adapter-netlify/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,14 @@
"index.d.ts"
],
"scripts": {
"dev": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -cw",
"build": "node -e \"fs.rmSync('files', { force: true, recursive: true })\" && rollup -c && node -e \"fs.cpSync('src/edge.js', 'files/edge.js')\"",
"test": "vitest run",
"dev": "rollup -cw",
"build": "rollup -c",
"check": "tsc",
"lint": "prettier --check .",
"format": "pnpm lint --write",
"test": "pnpm test:unit && pnpm test:integration",
"test:unit": "vitest run",
"test:integration": "pnpm build && pnpm -r --workspace-concurrency 1 --filter=\"./test/**\" test",
"prepublishOnly": "pnpm build"
},
"dependencies": {
Expand All @@ -46,6 +48,7 @@
"set-cookie-parser": "^2.6.0"
},
"devDependencies": {
"@netlify/edge-functions": "^2.15.1",
"@netlify/functions": "^4.0.0",
"@rollup/plugin-commonjs": "^28.0.1",
"@rollup/plugin-json": "^6.1.0",
Expand Down
27 changes: 23 additions & 4 deletions packages/adapter-netlify/rollup.config.js
Original file line number Diff line number Diff line change
@@ -1,20 +1,39 @@
import { nodeResolve } from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import { rmSync } from 'node:fs';

/**
* @param {string} filepath
* @returns {import('rollup').Plugin}
*/
function clearOutput(filepath) {
return {
name: 'clear-output',
buildStart: {
order: 'pre',
sequential: true,
handler() {
rmSync(filepath, { recursive: true, force: true });
}
}
};
}

/** @type {import('rollup').RollupOptions} */
const config = {
input: {
serverless: 'src/serverless.js',
shims: 'src/shims.js'
shims: 'src/shims.js',
edge: 'src/edge.js'
},
output: {
dir: 'files/esm',
dir: 'files',
format: 'esm'
},
// @ts-ignore https://github.com/rollup/plugins/issues/1329
plugins: [nodeResolve({ preferBuiltins: true }), commonjs(), json()],
external: (id) => id === '0SERVER' || id.startsWith('node:'),
plugins: [clearOutput('files'), nodeResolve({ preferBuiltins: true }), commonjs(), json()],
external: (id) => id === '0SERVER' || id === 'MANIFEST' || id.startsWith('node:'),
preserveEntrySignatures: 'exports-only'
};

Expand Down
28 changes: 21 additions & 7 deletions packages/adapter-netlify/src/edge.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,32 @@ import { manifest } from 'MANIFEST';

const server = new Server(manifest);

/**
* We don't know the origin until we receive a request, but
* that's guaranteed to happen before we call `read`
* @type {string}
*/
let origin;

const initialized = server.init({
// @ts-ignore
env: Deno.env.toObject()
env: Deno.env.toObject(),
read: async (file) => {
const response = await fetch(`${origin}/${file}`);
if (!response.ok) {
throw new Error(`Failed to fetch ${file}: ${response.status} ${response.statusText}`);
}
return response.body;
}
});

/**
* @param { Request } request
* @param { any } context
* @returns { Promise<Response> }
*/
/** @type {import('@netlify/edge-functions').EdgeFunction} */
export default async function handler(request, context) {
await initialized;
if (!origin) {
origin = new URL(request.url).origin;
await initialized;
}

return server.respond(request, {
platform: { context },
getClientAddress() {
Expand Down
6 changes: 6 additions & 0 deletions packages/adapter-netlify/test/apps/basic/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.DS_Store
node_modules
/.svelte-kit
/.netlify
/build
deno.lock
2 changes: 2 additions & 0 deletions packages/adapter-netlify/test/apps/basic/netlify.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[dev]
publish = "build"
Loading
Loading