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: allow embedding a sveltekit page as a widget in a third party website #13638

Closed
wants to merge 23 commits into from
Closed
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
5 changes: 5 additions & 0 deletions .changeset/olive-cougars-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: Introduce a way to embed a page as a widget in a third party app
2 changes: 2 additions & 0 deletions packages/kit/src/core/sync/write_types/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,8 @@ function update_types(config, routes, route, to_delete = new Set()) {
} else {
exports.push('export type PageProps = { data: PageData }');
}

exports.push('export type Embed = Kit.Embed<RouteParams, RouteId>');
}

if (route.layout) {
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 @@ -1447,6 +1447,15 @@ export type ActionResult<
| { type: 'redirect'; status: number; location: string }
| { type: 'error'; status?: number; error: any };

export type Embed<
Params extends Partial<Record<string, string>> = Partial<Record<string, string>>,
RouteId extends string | null = string | null
> = (event: RequestEvent<Params, RouteId>) => MaybePromise<EmbedResult>;

export type EmbedResult = {
target: string;
} | null;

/**
* The object returned by the [`error`](https://svelte.dev/docs/kit/@sveltejs-kit#error) function.
*/
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -483,13 +483,17 @@ Tips:
export const base = ${global}?.base ?? ${s(base)};
export const assets = ${global}?.assets ?? ${assets ? s(assets) : 'base'};
export const app_dir = ${s(kit.appDir)};
export const is_embed = ${global}?.is_embed ?? false;
export const embed_url = ${global}?.embed_url ?? null;
`;
}

return dedent`
export let base = ${s(base)};
export let assets = ${assets ? s(assets) : 'base'};
export const app_dir = ${s(kit.appDir)};
export const is_embed = ${global}?.is_embed ?? false;
export const embed_url = ${global}?.embed_url ?? null;

export const relative = ${svelte_config.kit.paths.relative};

Expand Down
6 changes: 5 additions & 1 deletion packages/kit/src/runtime/app/forms.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as devalue from 'devalue';
import { DEV } from 'esm-env';
import { invalidateAll } from './navigation.js';
import { app, applyAction } from '../client/client.js';
import { embed_url, is_embed } from '__sveltekit/paths';

export { applyAction };

Expand Down Expand Up @@ -104,8 +105,11 @@ export function enhance(form_element, submit = () => {}) {

// For success/failure results, only apply action if it belongs to the
// current page, otherwise `form` will be updated erroneously
// Exception: If the form is embedded in a third party page, we want to
// update it.
const location_url = is_embed ? new URL(embed_url) : location;
if (
location.origin + location.pathname === action.origin + action.pathname ||
location_url.origin + location_url.pathname === action.origin + action.pathname ||
result.type === 'redirect' ||
result.type === 'error'
) {
Expand Down
30 changes: 20 additions & 10 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
create_updated_store,
load_css
} from './utils.js';
import { base } from '__sveltekit/paths';
import { base, embed_url, is_embed } from '__sveltekit/paths';
import * as devalue from 'devalue';
import {
HISTORY_INDEX,
Expand Down Expand Up @@ -280,7 +280,7 @@ export async function start(_app, _target, hydrate) {
await _app.hooks.init?.();

routes = __SVELTEKIT_CLIENT_ROUTING__ ? parse(_app) : [];
container = __SVELTEKIT_EMBEDDED__ ? _target : document.documentElement;
container = __SVELTEKIT_EMBEDDED__ || is_embed ? _target : document.documentElement;
target = _target;

// we import the root layout/error nodes eagerly, so that
Expand Down Expand Up @@ -1288,6 +1288,7 @@ async function get_rerouted_url(url) {
*/
async function get_navigation_intent(url, invalidating) {
if (!url) return;

if (is_external_url(url, base, app.hash)) return;

if (__SVELTEKIT_CLIENT_ROUTING__) {
Expand Down Expand Up @@ -1330,10 +1331,17 @@ async function get_navigation_intent(url, invalidating) {

/** @param {URL} url */
function get_url_path(url) {
let url_path = url.pathname.slice(base.length);
if (is_embed) {
let base_path = new URL(base).pathname;
if (base_path.endsWith('/')) {
base_path = base_path.slice(0, -1);
}
url_path = url.pathname.slice(base_path.length);
}

return (
decode_pathname(
app.hash ? url.hash.replace(/^#/, '').replace(/[?#].+/, '') : url.pathname.slice(base.length)
) || '/'
decode_pathname(app.hash ? url.hash.replace(/^#/, '').replace(/[?#].+/, '') : url_path) || '/'
);
}

Expand Down Expand Up @@ -1537,11 +1545,13 @@ async function navigate({
[STATES_KEY]: state
};

const fn = replace_state ? history.replaceState : history.pushState;
fn.call(history, entry, '', url);
if (!is_embed) {
const fn = replace_state ? history.replaceState : history.pushState;
fn.call(history, entry, '', url);

if (!replace_state) {
clear_onward_history(current_history_index, current_navigation_index);
if (!replace_state) {
clear_onward_history(current_history_index, current_navigation_index);
}
}
}

Expand Down Expand Up @@ -2559,7 +2569,7 @@ async function _hydrate(
) {
hydrated = true;

const url = new URL(location.href);
const url = is_embed ? new URL(embed_url) : new URL(location.href);

/** @type {import('types').CSRRoute | undefined} */
let parsed_route;
Expand Down
10 changes: 8 additions & 2 deletions packages/kit/src/runtime/client/utils.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BROWSER, DEV } from 'esm-env';
import { writable } from 'svelte/store';
import { assets } from '__sveltekit/paths';
import { assets, is_embed } from '__sveltekit/paths';
import { version } from '__sveltekit/environment';
import { PRELOAD_PRIORITIES } from './constants.js';

Expand Down Expand Up @@ -312,7 +312,13 @@ export function create_updated_store() {
* @param {boolean} hash_routing
*/
export function is_external_url(url, base, hash_routing) {
if (url.origin !== origin || !url.pathname.startsWith(base)) {
if (url.origin !== origin) {
return true;
}

// in embed mode, the base should contain the origin,
// so it shouldnt' be checked against the url pathname
if (!is_embed && !url.pathname.startsWith(base)) {
return true;
}

Expand Down
7 changes: 6 additions & 1 deletion packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,10 @@ export async function render_page(event, page, options, manifest, state, nodes,

const csr = nodes.csr();

const embed_fn = nodes.embed();
/** @type {import('@sveltejs/kit').EmbedResult | undefined} */
const embed = embed_fn ? await embed_fn(event) : undefined;

/** @type {Array<Promise<Record<string, any> | null>>} */
const load_promises = nodes.data.map((node, i) => {
if (load_error) throw load_error;
Expand Down Expand Up @@ -304,7 +308,8 @@ export async function render_page(event, page, options, manifest, state, nodes,
resolve_opts,
page_config: {
csr: nodes.csr(),
ssr
ssr,
embed
},
status,
error: null,
Expand Down
64 changes: 54 additions & 10 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const encoder = new TextEncoder();
* options: import('types').SSROptions;
* manifest: import('@sveltejs/kit').SSRManifest;
* state: import('types').SSRState;
* page_config: { ssr: boolean; csr: boolean };
* page_config: { ssr: boolean; csr: boolean, embed?: import('@sveltejs/kit').EmbedResult | undefined };
* status: number;
* error: App.Error | null;
* event: import('@sveltejs/kit').RequestEvent;
Expand Down Expand Up @@ -96,6 +96,8 @@ export async function render_response({
*/
let base_expression = s(paths.base);

const is_embed = !!page_config.embed;

// if appropriate, use relative paths for greater portability
if (paths.relative) {
if (!state.prerendering?.fallback) {
Expand All @@ -113,6 +115,11 @@ export async function render_response({
// we have to assume that we're in the right place
base_expression = "new URL('.', location).pathname.slice(0, -1)";
}

if (is_embed) {
// if we're in embed mode, we need to preprend the current script origin as the base path
base_expression = `new URL(document.currentScript.src).origin + ${base_expression}`;
}
}

if (page_config.ssr) {
Expand Down Expand Up @@ -217,6 +224,7 @@ export async function render_response({

let head = '';
let body = rendered.html;
let embed_script = '';

const csp = new Csp(options.csp, {
prerender: !!state.prerendering
Expand Down Expand Up @@ -344,6 +352,11 @@ export async function render_response({

const properties = [`base: ${base_expression}`];

if (is_embed) {
properties.push('is_embed: true');
properties.push('embed_url: document.currentScript.src');
}

if (paths.assets) {
properties.push(`assets: ${s(paths.assets)}`);
}
Expand Down Expand Up @@ -383,7 +396,19 @@ export async function render_response({

const args = ['element'];

blocks.push('const element = document.currentScript.parentElement;');
if (is_embed) {
if (!page_config.embed?.target) {
throw new Error('Embed target is required in embed mode');
}
blocks.push(`
const element = document.querySelector(${s(page_config.embed?.target)});
if (!element) {
console.error('Embed target ${page_config.embed?.target} not found');
}
`);
} else {
blocks.push('const element = document.currentScript.parentElement;');
}

if (page_config.ssr) {
const serialized = { form: 'null', error: 'null' };
Expand Down Expand Up @@ -471,6 +496,9 @@ export async function render_response({
}
`;
csp.add_script(init_app);
if (is_embed) {
embed_script += init_app;
}

body += `\n\t\t\t<script${
csp.script_needs_nonce ? ` nonce="${csp.nonce}"` : ''
Expand All @@ -479,7 +507,7 @@ export async function render_response({

const headers = new Headers({
'x-sveltekit-page': 'true',
'content-type': 'text/html'
'content-type': is_embed ? 'application/javascript' : 'text/html'
});

if (state.prerendering) {
Expand Down Expand Up @@ -516,13 +544,29 @@ export async function render_response({
// add the content after the script/css links so the link elements are parsed first
head += rendered.head;

const html = options.templates.app({
head,
body,
assets,
nonce: /** @type {string} */ (csp.nonce),
env: safe_public_env
});
if (is_embed) {
// append head content to document head element
embed_script += `
document.head.insertAdjacentHTML('beforeend', ${s(head)});
`;
// append body content to embed target
embed_script += `
document.querySelector(${s(page_config.embed?.target)}).insertAdjacentHTML('beforeend', ${s(body)});
`;
}

let html = '';
if (is_embed) {
html = embed_script;
} else {
html = options.templates.app({
head,
body,
assets,
nonce: /** @type {string} */ (csp.nonce),
env: safe_public_env
});
}

// TODO flush chunks as early as we can
const transformed =
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/types/ambient-private.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ declare module '__sveltekit/paths' {
export let base: '' | `/${string}`;
export let assets: '' | `https://${string}` | `http://${string}` | '/_svelte_kit_assets';
export let app_dir: string;
export let is_embed: boolean;
export let embed_url: string;
export let relative: boolean;
export function reset(): void;
export function override(paths: { base: string; assets: string }): void;
Expand Down
5 changes: 4 additions & 1 deletion packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import {
Adapter,
ServerInit,
ClientInit,
Transporter
Transporter,
EmbedResult
} from '@sveltejs/kit';
import {
HttpMethod,
Expand Down Expand Up @@ -372,6 +373,7 @@ export interface UniversalNode {
prerender?: PrerenderOption;
ssr?: boolean;
csr?: boolean;
embed?: (event: RequestEvent) => EmbedResult | Promise<EmbedResult>;
trailingSlash?: TrailingSlash;
config?: any;
entries?: PrerenderEntryGenerator;
Expand All @@ -382,6 +384,7 @@ export interface ServerNode {
prerender?: PrerenderOption;
ssr?: boolean;
csr?: boolean;
embed?: (event: RequestEvent) => EmbedResult | Promise<EmbedResult>;
trailingSlash?: TrailingSlash;
actions?: Actions;
config?: any;
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/utils/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const valid_layout_exports = new Set([
'prerender',
'csr',
'ssr',
'embed',
'trailingSlash',
'config'
]);
Expand Down
8 changes: 4 additions & 4 deletions packages/kit/src/utils/exports.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ test('validates +layout.js', () => {
validate_layout_exports({
answer: 42
});
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, or anything with a '_' prefix)");
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, embed, trailingSlash, config, or anything with a '_' prefix)");

check_error(() => {
validate_layout_exports(
Expand Down Expand Up @@ -78,7 +78,7 @@ test('validates +page.js', () => {
validate_page_exports({
answer: 42
});
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, entries, or anything with a '_' prefix)");
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, embed, trailingSlash, config, entries, or anything with a '_' prefix)");

check_error(() => {
validate_page_exports(
Expand Down Expand Up @@ -114,7 +114,7 @@ test('validates +layout.server.js', () => {
validate_layout_server_exports({
answer: 42
});
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, or anything with a '_' prefix)");
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, embed, trailingSlash, config, or anything with a '_' prefix)");

check_error(() => {
validate_layout_exports(
Expand Down Expand Up @@ -152,7 +152,7 @@ test('validates +page.server.js', () => {
validate_page_server_exports({
answer: 42
});
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, trailingSlash, config, actions, entries, or anything with a '_' prefix)");
}, "Invalid export 'answer' (valid exports are load, prerender, csr, ssr, embed, trailingSlash, config, actions, entries, or anything with a '_' prefix)");

check_error(() => {
validate_page_server_exports({
Expand Down
Loading
Loading