Skip to content

feat(hosting): add SvelteKit framework adapter#202

Open
osama-rizk wants to merge 7 commits into
mainfrom
feat/hosting-sveltekit-adapter
Open

feat(hosting): add SvelteKit framework adapter#202
osama-rizk wants to merge 7 commits into
mainfrom
feat/hosting-sveltekit-adapter

Conversation

@osama-rizk

@osama-rizk osama-rizk commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

What

Adds a first-class SvelteKit adapter to @aws-blocks/hosting. SvelteKit apps are now auto-detected and deployed with the one-command flow — no manual configuration — joining the existing SPA, Next.js, Nuxt/Nitro, and Astro adapters.

Approach

SvelteKit's official @sveltejs/adapter-node emits a standard node build HTTP server, which we run on Lambda via the existing http-server compute type + Lambda Web Adapter — the same proven path as the Astro (@astrojs/node) and Nitro node-server adapters. This gives response streaming, tracks SvelteKit 2.x, and keeps custom runtime code minimal.

Because SvelteKit has no --config build flag, a transparent bridge temporarily rewrites svelte.config.js to force @sveltejs/adapter-node when the app hasn't wired it (auto-installing the dep via the detected package manager), and restores the original in a finally. The sample app configures adapter-node directly, so the happy path never mutates source.

Changes

  • packages/hosting/src/adapters/sveltekit.ts — version guard (≥2.0), transparent bridge, build runner, and DeployManifest builder:
    • http-server compute with HOST_HEADER/PROTOCOL_HEADER/BODY_SIZE_LIMIT LWA env (correct absolute URLs/redirects/cookies behind CloudFront; 20 MB body limit vs adapter-node's 512 KB default)
    • _app/immutable/* immutable asset caching, prerendered→S3 routing, error pages, paths.base
    • Normalizes SvelteKit's flat prerendered HTML (about.html) into directory-index form (about/index.html) so the L3 router's extensionless lookup (/aboutabout/index.html) resolves on S3
    • No image-opt Lambda (SvelteKit has no runtime image service; @sveltejs/enhanced-img is build-time only)
  • Register the adapter, add @sveltejs/kit framework detection, FrameworkType union member, and build default output dir (packages/core/src/hosting.ts)
  • Unit tests: sveltekit.test.ts, detectFramework cases, cross-adapter version-pin gate
  • test-apps/hosting-ssr-sveltekit/ — sample app exercising SSR, prerender/SSG, +server.js (GET/POST/PUT/DELETE), form actions, streaming, custom headers, cookies via hooks.server, server redirect, error(), hashed assets, and the single-origin /aws-blocks/* API proxy
  • Changeset (patch per the pre-1.0 caret convention — additive/backward-compatible)

Validation

  • Unit: full hosting suite green (802 passing)
  • Deploy: sample app deployed to a sandbox (CREATE/UPDATE_COMPLETE); every feature confirmed live via curl on the CloudFront URL
  • E2E: 49 passed / 0 failed across two consecutive full runs (new sveltekit project in osama-rizk/ssr-hosting-e2e; suite + config changes are in that repo, not this PR)

Test matrix

A SvelteKit per-framework matrix section is included at test-apps/hosting-ssr-sveltekit/TEST-MATRIX.md for appending to ssr-hosting-foundations/docs/01-test-matrix.md.

Notes

  • Two accompanying e2e-suite changes (a sveltekit.spec.ts, an app-base discovery marker, and a fair fix to the shared M21.3 Range assertion — a 206 with valid Content-Range is the real signal; S3 omits accept-ranges on the partial response) live in the separate ssr-hosting-e2e repo.
  • Build artifacts (build/, .svelte-kit/) are excluded via a per-app .gitignore mirroring the Nuxt/Astro apps.

Add a first-class SvelteKit adapter to @aws-blocks/hosting. SvelteKit apps are
auto-detected (@sveltejs/kit) and deployed via @sveltejs/adapter-node running on
Lambda behind the Lambda Web Adapter (existing http-server compute path), the
same proven model as the Astro (@astrojs/node) and Nitro node-server adapters.

- packages/hosting/src/adapters/sveltekit.ts: version guard (>=2.0), transparent
  svelte.config bridge that force-selects @sveltejs/adapter-node when unwired
  (auto-install + package-manager detection, restored in finally), build runner,
  and a DeployManifest builder (http-server compute with HOST_HEADER/
  PROTOCOL_HEADER/BODY_SIZE_LIMIT, _app/immutable/* immutable assets,
  prerendered->S3 routing, error pages, basePath). Normalizes SvelteKit's flat
  prerendered HTML (about.html) into directory-index form (about/index.html) so
  the L3 router's extensionless lookup resolves on S3.
- Register the adapter, add @sveltejs/kit framework detection, FrameworkType
  union, and the 'build' default output dir.
- Unit tests (sveltekit.test.ts), detectFramework cases, version-pin gate.
- test-apps/hosting-ssr-sveltekit: sample app exercising SSR, prerender/SSG,
  +server.js (all verbs), form actions, streaming, headers, cookies, redirect,
  error(), hashed assets, and the single-origin API proxy.

Deployed to a sandbox and verified end to end; e2e suite green (see
osama-rizk/ssr-hosting-e2e sveltekit project).
… load

The /about build timestamp changed on every reload even though S3 served frozen
HTML: `new Date()` in the component <script> (and later a universal +page.ts
load) re-ran during client hydration, overwriting the DOM with a fresh time —
making a genuinely-frozen prerendered page LOOK dynamic.

Move the timestamp into a +page.server.ts server load: it runs ONLY on the
server (at build time for a prerendered page) and its result is serialized into
the built HTML, never recomputed in the browser. Confirmed frozen across
reloads in a real browser.

@soberm soberm left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid implementation overall — the transparent bridge design, the flat→directory-index normalization for prerendered pages, and the LWA env wiring are all done thoughtfully. The test app is comprehensive and the VERIFIED_SVELTEKIT_RANGE export + cross-adapter version-pin test is a nice pattern.

Two issues need addressing before merge: the version guard only enforces the floor of the verified range (SvelteKit 3.x will silently pass), and the bridge will fire — and silently replace — any non-adapter-node adapter, not just cases where no adapter is wired. See inline comments.

const assertSvelteKitVersion = (projectDir: string): void => {
const info = getPackageInfoSync('@sveltejs/kit', { paths: [projectDir] });
const version = info?.version;
if (!version || !semver.gte(version, '2.0.0')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: This only enforces the floor of VERIFIED_SVELTEKIT_RANGE. A SvelteKit 3.x project will pass this check silently even though the verified range caps at <3.0.0 and the adapter's build-layout assumptions haven't been validated against a new major.

Should be:

if (!version || !semver.satisfies(version, VERIFIED_SVELTEKIT_RANGE)) {
  throw new HostingError('UnsupportedSvelteKitVersionError', {
    message: `SvelteKit version ${version ?? '(not installed)'} is outside the verified range ${VERIFIED_SVELTEKIT_RANGE}.`,
    ...
  });
}

The version_pins.test.ts test asserts VERIFIED_SVELTEKIT_RANGE contains a < bound — the runtime guard should honor it too.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. assertSvelteKitVersion now uses semver.satisfies(version, VERIFIED_SVELTEKIT_RANGE) instead of the floor-only gte("2.0.0"), so a 3.x install is rejected until the range is deliberately bumped after re-verification. Added a unit test asserting an above-range major (3.0.0) throws UnsupportedSvelteKitVersionError.

// node_modules presence is unreliable — adapter-node can land there
// transitively — so we key off the config text, the same signal Astro's
// adapter uses for its bridge decision.
const useBridge = !svelteConfigUsesAdapterNode(projectDir);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: svelteConfigUsesAdapterNode only scans for the string @sveltejs/adapter-node. If the user has a different adapter (@sveltejs/adapter-cloudflare, adapter-auto, a monorepo-local adapter), the scan returns false, useBridge = true, and the bridge silently installs adapter-node and replaces their configured adapter. The only signal is a process.stderr.write that fires inside the generated config during the build subprocess — after the user has already lost their original adapter choice.

Suggest checking whether the config references any @sveltejs/adapter-* import and erroring when a non-adapter-node adapter is detected rather than silently overwriting it:

const usesAnyAdapter = /@sveltejs\/adapter-/.test(configSrc);
if (usesAnyAdapter && !svelteConfigUsesAdapterNode(projectDir)) {
  throw new HostingError('SvelteKitIncompatibleAdapterError', {
    message: 'Your svelte.config uses an adapter incompatible with Lambda deployment.',
    resolution: 'Switch to @sveltejs/adapter-node, or remove the adapter and let the hosting adapter install it.',
  });
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. Added assertNoIncompatibleAdapter, called before the bridge decision: if the config references any @sveltejs/adapter-* that is not adapter-node, it throws SvelteKitIncompatibleAdapterError (switch to adapter-node, or remove the adapter and let hosting install it) rather than silently overwriting. adapter-node or no-adapter still flow through to the normal bridge path. Added a unit test with @sveltejs/adapter-cloudflare.


const buildBridgeConfigSource = (
originalConfigFile: string,
bodySizeLimit: number,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: bodySizeLimit is accepted here but never referenced in the template string — it's a dead parameter. The value is correctly wired as a BODY_SIZE_LIMIT env var in buildManifest (the right place for a runtime env var), so the behavior is fine, but this signature implies the value affects the generated config source when it doesn't. Just drop the parameter.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. Dropped bodySizeLimit from buildBridgeConfigSource (and from installSvelteKitBridge, which only forwarded it). The value stays wired as the BODY_SIZE_LIMIT env var in buildManifest.

if (has('yarn.lock')) {
return { command: 'yarn', args: ['add', '--silent', packageSpec] };
}
if (has('bun.lockb') || has('bun.lock')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: bun add <pkg> adds to dependencies, not devDependencies. @sveltejs/adapter-node is a dev dependency in every SvelteKit project. The npm path correctly uses --save-dev; Bun users would end up with it in dependencies.

return { command: 'bun', args: ['add', '-d', packageSpec] };

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. Bun now uses bun add -d. I applied the same reasoning to pnpm (-D) and yarn (--dev) since adapter-node is a devDependency in every SvelteKit project and those two would otherwise also land it in dependencies.


// Park the original config under a real `.js` name (so the bridge can import
// it), then write the bridge into the original config's slot.
fs.renameSync(userConfigPath, backupPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: BRIDGE_BACKUP_FILE is always svelte.config.blocks-original.js regardless of the original file's extension. When the original is svelte.config.mjs or svelte.config.ts, you rename a .mjs/.ts file to a .js path and the bridge imports it as ./svelte.config.blocks-original.js. This works today because Vite transpiles TypeScript content even from a .js path, but it's fragile — strict loader configs or future tooling changes could break it.

Consider preserving the original extension:

const backupExt = path.extname(userConfigPath) || '.js';
const backupPath = path.join(projectDir, `svelte.config.blocks-original${backupExt}`);

and updating the import in buildBridgeConfigSource accordingly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. Replaced the hardcoded BRIDGE_BACKUP_FILE with BRIDGE_BACKUP_BASENAME and now preserve the original extension: const backupExt = path.extname(userConfigPath) || ".js". So an .mjs/.ts source is parked as svelte.config.blocks-original.mjs/.ts, and the bridge imports that exact filename.

for (const htmlFile of walkHtmlFiles(prerenderedDir)) {
const rel = path.relative(prerenderedDir, htmlFile).replace(/\\/g, '/');
const urlPath = htmlFileToUrlPath(rel);
if (urlPath === '/') continue; // root is served by the catch-all / SSR

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: When / is prerendered it still gets merged into client/ (so the file lands in S3), but no static route is emitted for it — every homepage request hits Lambda. If this is intentional (to allow SSR to serve root even when a prerendered copy exists), worth a comment explaining why. If not, emitting { pattern: '/', target: 'static' } when build/prerendered/index.html exists would save a Lambda invocation on every homepage hit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. It was not intentional — now emitting { pattern: "/", target: "static" } when the root is prerendered (it is already merged into client/ as index.html), so the homepage serves from S3 instead of the SSR catch-all. The bare / static route precedes the /* catch-all; deeper paths still reach SSR. Added a unit test for both the prerendered and non-prerendered cases.

);
});
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SUGGESTION: The SvelteKitBridgeCollisionError path — thrown when svelte.config.blocks-original.js already exists on entry — has no unit test coverage. Given that the wrong behavior here would be silently overwriting the user's real config, a targeted test is worth adding: scaffold a project with a pre-existing backup file and assert the error is thrown before any file mutation occurs.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in c57cf6c. New test scaffolds a project with a pre-existing svelte.config.blocks-original.js and asserts SvelteKitBridgeCollisionError is thrown with the active config and backup left byte-identical. I also moved the collision guard ahead of installAdapterNode so it truly fires before any mutation (the install writes package.json/node_modules).

// from proxy headers so redirects / form-action URLs / cookies point
// at the viewer domain, not the raw Lambda host. The KVS router copies
// Host → x-forwarded-host on compute-bound requests.
// eslint-disable-next-line @typescript-eslint/naming-convention

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK: Three consecutive eslint-disable-next-line comments for the same rule is noisy. A single block disable around the whole environment object reads cleaner:

/* eslint-disable @typescript-eslint/naming-convention */
environment: {
  HOST_HEADER: 'x-forwarded-host',
  PROTOCOL_HEADER: 'x-forwarded-proto',
  BODY_SIZE_LIMIT: String(bodySizeLimit),
},
/* eslint-enable @typescript-eslint/naming-convention */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. Collapsed the three eslint-disable-next-line comments into a single /* eslint-disable @typescript-eslint/naming-convention */ ... /* eslint-enable */ block around the environment object.

"telemetry": {
"projectId": "1d97c564-fa80-410a-9dd0-59feaa841900"
}
} No newline at end of file

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NITPICK: Missing newline at end of file.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c57cf6c. Added the trailing newline.

- Enforce full VERIFIED_SVELTEKIT_RANGE in the version guard (reject 3.x,
  not just < 2.0) via semver.satisfies
- Throw SvelteKitIncompatibleAdapterError when the config wires a non
  adapter-node adapter, instead of silently overwriting it
- Check the bridge-backup collision guard before any mutation (install/
  rename), so a collision aborts cleanly
- Preserve the original svelte.config extension for the bridge backup
  (.mjs/.ts) rather than forcing .js
- Pass each package manager's dev flag (npm --save-dev, pnpm -D, yarn
  --dev, bun -d) so adapter-node installs as a devDependency
- Drop the dead bodySizeLimit param from buildBridgeConfigSource
- Emit a static / route when the root page is prerendered, saving a
  Lambda invocation on every homepage request
- Collapse the per-line eslint-disable comments into a single block
- Add tests for the collision guard, incompatible-adapter rejection,
  above-range version rejection, and prerendered-root routing
- Add trailing newline to the sveltekit test app config.json
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9d9e4f5

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@aws-blocks/hosting Patch
@aws-blocks/core Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@osama-rizk

Copy link
Copy Markdown
Contributor Author

Thanks for the review! All feedback addressed in c57cf6c — replied inline on each thread. Summary:

Critical

  • Version guard now enforces the full VERIFIED_SVELTEKIT_RANGE (rejects 3.x, not just < 2.0)
  • Non-adapter-node adapters now throw SvelteKitIncompatibleAdapterError instead of being silently overwritten

Suggestions/nitpicks

  • Dropped the dead bodySizeLimit param from buildBridgeConfigSource
  • Dev-flag install for bun (-d), plus pnpm (-D) / yarn (--dev) for the same reason
  • Bridge backup preserves the original config extension (.mjs/.ts)
  • Static / route emitted when the root is prerendered
  • Collision guard moved ahead of any mutation; added its unit test
  • Single block eslint-disable; trailing newline on config.json

Validation

  • Unit: hosting suite 807 passing / 0 failing (added 5 tests: collision guard, incompatible-adapter, above-range version, prerendered-root routing x2)
  • Deployed to a sandbox and re-ran e2e: sveltekit project 50 passed / 0 failed

@ahmedhamouda78 ahmedhamouda78 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed against the current head (post-c57cf6c). The c57cf6c round addressed the earlier feedback well — version ceiling, the incompatible-adapter guard, collision-guard ordering, extension preservation, per-PM dev flags, and the prerendered / route are all solid. Three issues remain that each have a realistic path to data loss or silent misbehaviour, plus a few minors/nits. All are targeted, low-risk fixes. Note: these paths are currently untested (unit tests use skipBuild: true), which is why CI is green despite them. Severity tags: [major] blocking, [minor] should-fix, [nit] optional.


// Park the original config under its preserved name (so the bridge can import
// it), then write the bridge into the original config's slot.
fs.renameSync(userConfigPath, backupPath);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Config left broken if the bridge write fails. installSvelteKitBridge does renameSync(userConfig → backup) then writeFileSync(userConfig, bridge). If the write throws (disk full, EROFS, permissions), the exception propagates before the cleanup closure is returned, so the caller’s finally { cleanupBridge?.() } never runs — the user’s config is stranded at the backup path with no svelte.config.* in place (recovery needs a manual rename). Restore before rethrowing:

fs.renameSync(userConfigPath, backupPath);
try {
  fs.writeFileSync(userConfigPath, buildBridgeConfigSource(backupFile), 'utf-8');
} catch (writeErr) {
  try { fs.renameSync(backupPath, userConfigPath); } catch { /* best-effort */ }
  throw writeErr;
}

This failure path is untested — all bridge tests use skipBuild: true.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. The writeFileSync is now wrapped in a try/catch that renames the backup back to the original path before rethrowing, exactly as suggested — so a failed write no longer strands the config at the backup path. (The restore is best-effort; if even that fails, the collision guard surfaces the leftover loudly on the next run.)

*/
const prunePreCompressedAssets = (dir: string): void => {
if (!fs.existsSync(dir)) return;
const compressed = fg.sync('**/*.{gz,br,zst}', {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] prunePreCompressedAssets silently deletes user static files. The glob **/*.{gz,br,zst} over build/client/ matches everything, not just adapter-node’s precompress siblings. A user’s static/downloads/dataset.tar.gz or static/fonts/custom.woff2.br gets merged into client/ and then rmSync’d — the S3 upload is missing the file with no warning. Only delete when the uncompressed sibling exists:

for (const f of compressed) {
  const base = f.replace(/\.(gz|br|zst)$/i, '');
  if (fs.existsSync(base)) fs.rmSync(f);
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. prunePreCompressedAssets now only deletes a .gz/.br/.zst when its uncompressed sibling exists, so a user's standalone static/dataset.tar.gz or custom.woff2.br survives to S3. Added a unit test covering both the precompress-sibling (pruned) and standalone-archive (kept) cases.

if (!configPath) return false;
try {
const src = fs.readFileSync(configPath, 'utf-8');
return /@sveltejs\/adapter-node/.test(src);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Adapter detection is defeated by commented-out imports (follow-up to the earlier adapter-detection thread). svelteConfigUsesAdapterNode (and assertNoIncompatibleAdapter at L293) run the regex over raw source including comments, so the assertNoIncompatibleAdapter guard added in c57cf6c is still bypassed. A commented-out adapter-node import with an active incompatible adapter:

// import node from '@sveltejs/adapter-node'; // old — switched to cloudflare
import cloudflare from '@sveltejs/adapter-cloudflare';

makes svelteConfigUsesAdapterNode return true → bridge skipped AND the incompatible-adapter check short-circuits to false → build proceeds with the wrong adapter, producing an unrunnable Lambda bundle. The inverse (commented adapter, none active) throws a spurious error. Strip comments first:

const stripped = src.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');

Worth a test for 'stale adapter-node comment + active incompatible adapter'.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. Added a shared stripComments helper (+ readSvelteConfigSource) applied in svelteConfigUsesAdapterNode, assertNoIncompatibleAdapter, and textScanSvelteConfig. A commented-out adapter-node import no longer counts as "wired", so the incompatible-adapter guard fires correctly; the inverse (commented adapter, none active) no longer throws spuriously. Added tests for both directions using your exact stale-comment example.

for (const entry of fs.readdirSync(clientDir, { withFileTypes: true })) {
if (entry.name === appDir) continue; // handled above
// Skip prerendered HTML index files — handled as page routes below.
if (entry.isFile() && entry.name.endsWith('.html')) continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Generic static .html still routed through Lambda (follow-up to the prerendered-/ fix). The prerendered-root fix is correct, but this loop skips ALL flat .html, so a user’s static/terms.htmlclient/terms.html gets no S3 route and falls through to the SSR Lambda (served, but wasted compute per hit). Narrow the skip to known files and route the rest to S3 — and fix the comment, which says 'prerendered HTML index files' but applies to everything:

const SKIP = new Set(['index.html', '404.html', '500.html']);
if (entry.isFile() && entry.name.endsWith('.html') && !SKIP.has(entry.name)) {
  add({ pattern: `/${entry.name}`, target: 'static' });
  continue;
}
if (entry.isFile() && entry.name.endsWith('.html')) continue;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. The .html skip is narrowed to index.html/404.html/500.html (via a ROOT_AND_ERROR_HTML set); any other top-level flat .html (e.g. static/terms.html) now gets a { pattern: "/<name>", target: "static" } S3 route, and I corrected the misleading comment. Added a test asserting terms.html routes to S3 while the root/error pages do not get bare routes.

buildCommand && buildCommand.length > 0
? buildCommand
: projectHasBuildScript(projectDir)
? ['npm', 'run', 'build']

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Build step still hardcodes npm (follow-up to the install-step PM fix). c57cf6c made the install PM-aware via detectPackageManagerInstall, but runSvelteKitBuild still defaults to ['npm','run','build']. So a pnpm/yarn/bun project installs with the right manager then builds with npm — inconsistent, and it fails outright on Yarn PnP, strict pnpm (node-linker=isolated), or Bun-only CI without npm on PATH. Extract a detectPackageManagerRun(projectDir) twin and use it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. Extracted detectPackageManager and added a detectPackageManagerRun twin; runSvelteKitBuild now runs pnpm run / yarn <script> / bun run / npm run to match the detected manager, so install and build stay consistent (no npm on a Yarn PnP / strict-pnpm / Bun-only CI path).

const out: SvelteConfigShape = { kit: {} };
const appDir = src.match(/\bappDir\s*:\s*['"`]([^'"`]+)['"`]/);
if (appDir) out.kit!.appDir = appDir[1];
const base = src.match(/\bbase\s*:\s*['"`]([^'"`]*)['"`]/);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] textScanSvelteConfig base: regex matches comments/strings — it would extract /old from // base: '/old'. Only the jiti-failure fallback, but a wrong basePath → wrong CloudFront prefix → broken routing. Strip comments first (same helper as above).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5 by the same stripComments helper — textScanSvelteConfig now scans comment-stripped source, so a commented-out base: '/old' no longer leaks into the resolved basePath.


const installAdapterNode = (projectDir: string): void => {
// Idempotent: skip if adapter-node is already resolvable from the project.
if (getPackageInfoSync('@sveltejs/adapter-node', { paths: [projectDir] })) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] installAdapterNode skips install for ANY resolvable version, including a transitive ^4, whose build/ layout can differ from the pinned ^5. The @sveltejs/kit guard uses semver.satisfies; do the same here:

const info = getPackageInfoSync('@sveltejs/adapter-node', { paths: [projectDir] });
if (info?.version && semver.satisfies(info.version, '^5')) return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. installAdapterNode now skips only when the resolvable @sveltejs/adapter-node satisfies the pinned major (semver.satisfies(version, ADAPTER_NODE_RANGE) where ADAPTER_NODE_RANGE = "^5"), matching the @sveltejs/kit guard — a transitive ^4 is treated as not-installed and the pin is installed.

// Preserve the original config's extension so the parked module resolves the
// same way the original did (an `.mjs`/`.ts` source keeps its loader
// semantics rather than being renamed to `.js`).
const backupExt = path.extname(userConfigPath) || '.js';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] Collision guard misses cross-extension stale backups. It keys backupPath off the current config’s extension, so a stale svelte.config.blocks-original.js from a prior crash is invisible once the user migrates to .mjs/.ts — the run proceeds and leaves an orphan backup. Check all backup-basename variants (over SVELTE_CONFIG_FILES extensions) instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9d9e4f5. Added findStaleBridgeBackup, which checks every backup-basename variant across BRIDGE_BACKUP_EXTENSIONS (.js/.mjs/.ts) rather than only the current config's extension, so a stale .js backup is caught after the user migrates to .mjs/.ts. Added a test for the .js-backup + .mjs-config case.

- Restore the original svelte.config if the bridge write fails, so a
  disk-full/EROFS/permission error can't strand the user's config at the
  backup path (cleanup closure isn't returned yet at that point)
- prunePreCompressedAssets only deletes a .gz/.br/.zst when its
  uncompressed sibling exists, so a user's standalone static archive
  (dataset.tar.gz, font.woff2.br) is no longer silently dropped
- Strip comments before all svelte.config text scans, via a shared
  helper — a commented-out adapter-node import no longer defeats the
  incompatible-adapter guard, and a commented base:'/old' no longer
  leaks into the resolved basePath
- Route generic top-level static .html (e.g. static/terms.html) to S3
  instead of the SSR Lambda; skip only index/404/500
- Make the build command package-manager-aware (detectPackageManagerRun
  twin of the install detector), so pnpm/yarn/bun projects don't build
  with npm
- installAdapterNode skips the install only when the resolvable
  adapter-node satisfies the pinned major (^5), matching the kit guard
- Collision guard checks every backup extension variant, catching a
  stale .js backup after the user migrates the config to .mjs/.ts
- Tests: comment-stripping (both directions), cross-extension collision,
  static-html routing, precompress-sibling prune
@osama-rizk

Copy link
Copy Markdown
Contributor Author

Thanks @ahmedhamouda78 — all 8 follow-up comments addressed in 9d9e4f5 (replied inline on each thread).

Major

  • Restore original config if the bridge write fails (no stranded config)
  • prunePreCompressedAssets only deletes a compressed file when its uncompressed sibling exists — user standalone archives survive
  • Strip comments before every svelte.config text scan (shared helper), so a commented-out adapter import no longer defeats the incompatible-adapter guard and a commented base: no longer leaks into the basePath

Minor

  • Route generic top-level static .html (e.g. static/terms.html) to S3; skip only index/404/500
  • Build command is now package-manager-aware (detectPackageManagerRun), matching the install step

Nits

  • installAdapterNode skips only when the resolvable adapter-node satisfies ^5
  • Collision guard checks all backup extensions (.js/.mjs/.ts)

Validation

  • Unit: hosting suite 813 passing / 0 failing (+6 tests: comment-stripping both directions, cross-extension collision, static-html routing, precompress-sibling prune)
  • Redeployed to sandbox; sveltekit e2e 50 passed / 0 failed (dedicated project run). The full parallel cross-framework run shows only the shared home dashboard: all probes pass 45s-timeout flake under Lambda contention (hits next-app too, and passes 3/3 in isolation) plus 3 pre-existing next-app/nuxt failures unrelated to this adapter.

Rebased onto the latest branch (post main-merge) before pushing.

@ahmedhamouda78 ahmedhamouda78 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed after 9d9e4f5 — all 8 items from my previous review are resolved. Verified against the diff: the prunePreCompressedAssets sibling guard, the shared stripComments helper applied across svelteConfigUsesAdapterNode / assertNoIncompatibleAdapter / textScanSvelteConfig, the narrowed .html routing with static-HTML→S3, the cross-extension stale-backup scan, the semver-gated adapter-node install, the writeFileSync restore-on-failure, and detectPackageManagerRun for the build step are all correctly implemented, with new tests covering the sibling-prune, both comment-detection directions, terms.html→S3, and the cross-extension backup cases. Nice work. Approving. 🚢

@@ -0,0 +1,5 @@
{
"telemetry": {
"projectId": "1d97c564-fa80-410a-9dd0-59feaa841900"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] This projectId looks like a real UUID (1d97c564-fa80-410a-9dd0-59feaa841900). The sibling test apps (hosting-ssr-astro, hosting-ssr-nuxt) both commit the shared placeholder 00000000-0000-0000-0000-000000006e2e for exactly this reason — a realistic-looking ID risks sending test-app telemetry to a live project. Should swap it for the placeholder.

@@ -0,0 +1,18 @@
<script lang="ts">
import type { PageData } from './$types';
// Frozen at build time via the prerendered load (see +page.ts). A fresh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[NITPICK] Comment says "(see +page.ts)" but there's no +page.ts in this directory — the load lives in +page.server.ts. Should be "(see +page.server.ts)".

@soberm

soberm commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

[LOW] packages/hosting/README.md line 14 + line 54 — the adapter list and import example don't include SvelteKit.

The Overview still reads "(Next.js, Nuxt, Astro, SPA)" and the sub-path import example on line 54 only pulls in nextjsAdapter, nuxtAdapter, astroAdapter, and spaAdapter, even though src/adapters/index.ts now exports sveltekitAdapter/SveltekitAdapterOptions and registers 'sveltekit'. This README is published to npm, so anyone reading the package docs won't find the new adapter.

Fix: add SvelteKit to the prose list → "Next.js, Nuxt, Astro, SvelteKit, SPA" and add sveltekitAdapter to the import line.

(Note: posted here because the README wasn't modified in this PR and GitHub doesn't allow inline comments on files outside the diff.)

@bobbor

bobbor commented Jul 21, 2026

Copy link
Copy Markdown

@osama-rizk nice work on the follow-ups 👍 The two hard blockers from my earlier pass are gone: precompressed pruning now checks for an uncompressed sibling, and adapter rejection fails loudly instead of silently swapping. I re-checked the full diff after 9d9e4f5; here's what still stands. Nothing left is data-loss or silent-failure class, so treat these as comment-level feedback, not blockers 🙂

Design questions

  1. adapter-auto: the guard now deliberately rejects it, but every npx sv create scaffold ships adapter-auto, so first deploy hits SvelteKitIncompatibleAdapterError instead of the zero-config flow the PR description promises. Since adapter-auto is designed to be substituted per target, bridging it should be safe. Intentional? Either way, there's no adapter-auto unit test pinning the behavior.
  2. Community adapters: svelte-adapter-bun, svelte-kit-sst, or a monorepo-local adapter don't match the @sveltejs/adapter-* guard regex, so the bridge still silently replaces them; that's the exact failure mode the guard was added for. Maybe scan for any adapter: wiring in the config, or at least call it out in docs.
  3. Route emission at scale: one static route per prerendered page plus ${urlPath}/* can hit CloudFront's default cache-behavior quota (~25) on content-heavy sites, with no cap or warning. Related: /about prerendered + /about/[slug] SSR means the subtree route can shadow the SSR child into an S3 404. Have you tried a site with 30+ prerendered pages?

Smaller stuff

  1. Deploy still mutates the user's source tree (svelte.config rewrite + package.json/lockfile install). The restore-on-failure in 9d9e4f5 was a good add, but the restore itself still swallows errors, and a read-only checkout will fail on the rename. If this is the accepted Astro/Nitro precedent, a docs note would help.
  2. Hardcoded build/ output dir breaks adapter({ out: 'dist' }) configs; nodejs20.x runtime is hardcoded too.
  3. The Lambda bundle ships client/ + prerendered/ that S3 already serves: bundle-size/cold-start cost for free.
  4. FLAT_HTML_NAMES basename check applies at any depth, so blog/404.html stays flat and its bare route 404s.
  5. The pnpm/yarn/bun dev-flag install paths still have no dedicated unit tests.

Repo hygiene

  1. PR body + TEST-MATRIX.md link to personal repos (osama-rizk/ssr-hosting-e2e, ssr-hosting-foundations): dead links for everyone else in a public repo, and the SK4 rows reference a basePath variant app that isn't in this PR. Maybe TEST-MATRIX.md shouldn't ship here at all?
  2. The changeset declares this feat as patch citing the "pre-1.0 caret convention"; fine if that's the established convention, but that call should come from a maintainer rather than the changeset itself.
  3. test-apps/hosting-ssr-sveltekit/.blocks/config.json commits a telemetry projectId UUID; is that the convention for the other test-apps?
  4. The lockfile flips @types/trusted-types to non-optional (svelte 5.x declares it as a hard dep, so probably benign; just confirming it's expected churn).

None of these block the merge from my side. Nice adapter overall, the flat→directory-index normalization and the version-pin gate are 💯

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants