Skip to content
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

feat: import maps #11615

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
5 changes: 5 additions & 0 deletions .changeset/nice-readers-crash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add importMap.enabled option for generating import maps
3 changes: 3 additions & 0 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ const get_defaults = (prefix = '') => ({
appTemplate: join(prefix, 'src/app.html'),
errorTemplate: join(prefix, 'src/error.html')
},
importMap: {
enabled: false
},
inlineStyleThreshold: 0,
moduleExtensions: ['.js', '.ts'],
output: { preloadStrategy: 'modulepreload' },
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,10 @@ const options = object(
errorTemplate: string(join('src', 'error.html'))
}),

importMap: object({
enabled: boolean(false)
}),

inlineStyleThreshold: number(0),

moduleExtensions: string_array(['.js', '.ts']),
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export const options = {
env_public_prefix: '${config.kit.env.publicPrefix}',
env_private_prefix: '${config.kit.env.privatePrefix}',
hooks: null, // added lazily, via \`get_hooks\`
import_map_enabled: ${config.kit.importMap.enabled},
preload_strategy: ${s(config.kit.output.preloadStrategy)},
root,
service_worker: ${has_service_worker},
Expand Down
9 changes: 9 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -432,6 +432,15 @@ export interface KitConfig {
*/
errorTemplate?: string;
};
importMap?: {
/**
* Whether to generate import maps. This will result in better long term cacheability, as changes to a single module will no longer invalidate all its dependents.
* However, it will increase the size of the HTML document, and force `modulepreload` links to be part of the document rather than being added as HTTP headers.
* @default false;
* @since 2.4.0
*/
enabled?: boolean;
};
/**
* Inline CSS inside a `<style>` block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the [String.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) property, to be inlined. All CSS files needed for the page and smaller than this value are merged and inlined in a `<style>` block.
*
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/vite/dev/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ export async function dev(vite, vite_config, svelte_config) {
imports: [],
stylesheets: [],
fonts: [],
uses_env_dynamic_public: true
uses_env_dynamic_public: true,
import_map_lookup: []
},
nodes: manifest_data.nodes.map((node, index) => {
return async () => {
Expand Down
125 changes: 120 additions & 5 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,8 @@ async function kit({ svelte_config }) {
}
};

const hash_data_file = `${kit.outDir}/output/hash-data.json`;

/** @type {import('vite').Plugin} */
const plugin_compile = {
name: 'vite-plugin-sveltekit-compile',
Expand Down Expand Up @@ -612,8 +614,16 @@ async function kit({ svelte_config }) {
input,
output: {
format: 'esm',
entryFileNames: ssr ? '[name].js' : `${prefix}/[name].[hash].${ext}`,
chunkFileNames: ssr ? 'chunks/[name].js' : `${prefix}/chunks/[name].[hash].${ext}`,
entryFileNames: ssr
Copy link
Member

Choose a reason for hiding this comment

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

could probably use a comment here about how we're controlling the hashing when import map is enabled and how it interacts with that

? '[name].js'
: kit.importMap.enabled
? `${prefix}/[name].${ext}`
: `${prefix}/[name].[hash].${ext}`,
chunkFileNames: ssr
? 'chunks/[name].js'
: kit.importMap.enabled
? `${prefix}/chunks/[name].${ext}`
: `${prefix}/chunks/[name].[hash].${ext}`,
assetFileNames: `${prefix}/assets/[name].[hash][extname]`,
hoistTransitiveImports: false,
sourcemapIgnoreList
Expand Down Expand Up @@ -702,8 +712,99 @@ async function kit({ svelte_config }) {
*/
writeBundle: {
sequential: true,
async handler(_options) {
if (secondary_build_started) return; // only run this once
async handler(options, bundle) {
if (secondary_build_started) {
if (svelte_config.kit.importMap.enabled) {
Copy link
Member

Choose a reason for hiding this comment

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

would this new chunk of code fit in a separate function? It's hard to tell on github whether it's accessing many things from outside its scope

const length = `${svelte_config.kit.appDir}/immutable/`.length;

/** @type {Record<string, string>} */
const renames = {};

/** @type {Record<string, string>} */
const replacements = {};

for (const chunk of Object.values(bundle)) {
if (chunk.type === 'chunk') {
const h = hash(chunk.code);

renames[chunk.fileName] = chunk.fileName.replace(/\.\w+$/, (m) => `.${h}${m}`);
replacements[chunk.fileName] = chunk.fileName.replace(/\.\w+$/, '').slice(length);
}
}

/** @type {Record<string, string>} */
const lookup = {};

for (const chunk of Object.values(bundle)) {
if (chunk.type !== 'chunk') continue;

/** @type {string[]} */
const relative_imports = [];

/** @param {string} relative */
const resolve = (relative) => {
const from = chunk.fileName.split('/').slice(0, -1);
const to = relative.split('/');

while (to[0] === '..') {
to.shift();
from.pop();
}

if (to[0] === '.') to.shift();

return [...from, ...to].join('/');
};

// we do the replace here, instead of in renderChunk, so that it happens after
// other Vite transformations (especially the removal of empty CSS chunks, and
// replacement of __VITE_PRELOAD__ inserts)
let code = chunk.code.replace(
/(from ?|import ?|import\()"(.+?)"/g,
(m, prefix, relative) => {
const resolved = resolve(relative);
const replacement = replacements[resolved];

if (replacement) {
relative_imports.push(relative);
lookup[replacement] = renames[resolved];
return `${prefix}'${replacement}'`;
}

return m;
}
);

// this part is super hacky!
if (code.includes('__vite__mapDeps')) {
code = code.replace(/__vite__mapDeps\.viteFileDeps = (\[.+\])/, (_, deps) => {
const mapped = /** @type {string[]} */ (JSON.parse(deps))
.map((dep) => {
const resolved = resolve(dep);
const renamed = renames[resolved];

if (renamed) {
return `"${path.relative(path.dirname(chunk.fileName), renamed)}"`;
}

return `"${dep}"`;
})
.join(', ');

return `__vite__mapDeps.viteFileDeps = [${mapped}]`;
});
}

fs.writeFileSync(`${options.dir}/${renames[chunk.fileName]}`, code);
fs.unlinkSync(`${options.dir}/${chunk.fileName}`);
}

// write metadata to disk, so that it can be used to generate import maps at render time etc
fs.writeFileSync(hash_data_file, JSON.stringify({ renames, lookup }));
}

return; // only run this once, for the client build
}

const verbose = vite_config.logLevel === 'info';
const log = logger({ verbose });
Expand Down Expand Up @@ -776,9 +877,22 @@ async function kit({ svelte_config }) {
`${out}/client/${kit.appDir}/immutable/assets`
);

/** @type {Array<[string, string]>} */
let import_map_lookup = [];

/** @type {import('vite').Manifest} */
const client_manifest = JSON.parse(read(`${out}/client/${vite_config.build.manifest}`));

if (fs.existsSync(hash_data_file)) {
const hash_data = JSON.parse(read(hash_data_file));

for (const chunk of Object.values(client_manifest)) {
chunk.file = hash_data.renames[chunk.file];
}

import_map_lookup = Object.entries(hash_data.lookup);
}

const deps_of = /** @param {string} f */ (f) =>
find_deps(client_manifest, posixify(path.relative('.', f)), false);
const start = deps_of(`${runtime_directory}/client/entry.js`);
Expand All @@ -792,7 +906,8 @@ async function kit({ svelte_config }) {
fonts: [...start.fonts, ...app.fonts],
uses_env_dynamic_public: output.some(
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
)
),
import_map_lookup
};

const css = output.filter(
Expand Down
23 changes: 21 additions & 2 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,25 @@ export async function render_response({
}

if (page_config.csr) {
if (options.import_map_enabled && !__SVELTEKIT_DEV__) {
const import_map = `
{
"imports": {
${client.import_map_lookup
.map(([key, value]) => `${s(key)}: ${s(prefixed(value))}`)
.join(',\n\t\t\t\t\t')}
}
}
`;
csp.add_script(import_map);

let attrs = 'type="importmap"';
if (csp.script_needs_nonce) attrs += ` nonce="${csp.nonce}"`;

head += `
<script ${attrs}>${import_map}</script>`;
}

if (client.uses_env_dynamic_public && state.prerendering) {
modulepreloads.add(`${options.app_dir}/env.js`);
}
Expand All @@ -290,7 +309,7 @@ export async function render_response({
link_header_preloads.add(`<${encodeURI(path)}>; rel="modulepreload"; nopush`);
if (options.preload_strategy !== 'modulepreload') {
head += `\n\t\t<link rel="preload" as="script" crossorigin="anonymous" href="${path}">`;
} else if (state.prerendering) {
} else if (state.prerendering || options.import_map_enabled) {
head += `\n\t\t<link rel="modulepreload" href="${path}">`;
}
}
Expand Down Expand Up @@ -448,7 +467,7 @@ export async function render_response({
headers.set('content-security-policy-report-only', report_only_header);
}

if (link_header_preloads.size) {
if (link_header_preloads.size && !options.import_map_enabled) {
headers.set('link', Array.from(link_header_preloads).join(', '));
}
}
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export interface BuildData {
stylesheets: string[];
fonts: string[];
uses_env_dynamic_public: boolean;
import_map_lookup: Array<[string, string]>;
} | null;
server_manifest: import('vite').Manifest;
}
Expand Down Expand Up @@ -344,6 +345,7 @@ export interface SSROptions {
env_public_prefix: string;
env_private_prefix: string;
hooks: ServerHooks;
import_map_enabled: boolean;
preload_strategy: ValidatedConfig['kit']['output']['preloadStrategy'];
root: SSRComponent['default'];
service_worker: boolean;
Expand Down
3 changes: 3 additions & 0 deletions packages/kit/test/apps/options/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ const config = {
serviceWorker: 'source/service-worker'
},
appDir: '_wheee',
importMap: {
enabled: true
},
inlineStyleThreshold: 1024,
outDir: '.custom-out-dir',
output: {
Expand Down
27 changes: 27 additions & 0 deletions packages/kit/test/apps/options/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -312,3 +312,30 @@ test.describe('Routing', () => {
await expect(page.locator('h2')).toHaveText('target: 0');
});
});

test.describe('import maps', () => {
test('generates an import map', async ({ page, request }) => {
await page.goto('/path-base');

if (process.env.DEV) {
expect(await page.locator('script[type="importmap"]').count()).toBe(0);
} else {
const json = await page.locator('script[type="importmap"]').first().textContent();
expect(json).toContain('"imports"');

const map = JSON.parse(json);
for (const pathname of Object.values(map.imports)) {
const response = await request.get(pathname);
const js = await response.text();

// check that imports are not relative
const pattern = /(from ?|import ?|import\()(['"])(.+?)\2/g;
let match;
while ((match = pattern.exec(js))) {
const [, , , path] = match;
expect(path).not.toMatch(/^\./);
}
}
}
});
});
10 changes: 10 additions & 0 deletions packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,15 @@ declare module '@sveltejs/kit' {
*/
errorTemplate?: string;
};
importMap?: {
/**
* Whether to generate import maps. This will result in better long term cacheability, as changes to a single module will no longer invalidate all its dependents.
Copy link
Member

Choose a reason for hiding this comment

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

is there any guidance we can offer as to when this should be done? like maybe it's worth it until the site grows to a certain size?

* However, it will increase the size of the HTML document, and force `modulepreload` links to be part of the document rather than being added as HTTP headers.
* @default false;
* @since 2.4.0
*/
enabled?: boolean;
};
/**
* Inline CSS inside a `<style>` block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the [String.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) property, to be inlined. All CSS files needed for the page and smaller than this value are merged and inlined in a `<style>` block.
*
Expand Down Expand Up @@ -1557,6 +1566,7 @@ declare module '@sveltejs/kit' {
stylesheets: string[];
fonts: string[];
uses_env_dynamic_public: boolean;
import_map_lookup: Array<[string, string]>;
} | null;
server_manifest: import('vite').Manifest;
}
Expand Down
4 changes: 4 additions & 0 deletions sites/kit.svelte.dev/svelte.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ const config = {
runtime: 'edge'
}),

importMap: {
enabled: true
},

paths: {
relative: true
}
Expand Down
Loading