Skip to content

feat: typed +server.ts responses with fetch #11108

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

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
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
66 changes: 57 additions & 9 deletions packages/kit/src/core/sync/write_types/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import MagicString from 'magic-string';
import { posixify, rimraf, walk } from '../../../utils/filesystem.js';
import { compact } from '../../../utils/array.js';
import { ts } from '../ts.js';
import { dedent } from '../utils.js';

/**
* @typedef {{
Expand Down Expand Up @@ -335,7 +336,41 @@ function update_types(config, routes, route, to_delete = new Set()) {
}

if (route.endpoint) {
// get the from/to for the proxy
const from = path_to_original(outdir, route.endpoint.file);
// We have to find out which methods are available/exported on the endpoint.
const proxy = createProxy(route.endpoint.file, 'endpoint');

if(proxy) {
// For each exported method, we create a type that represents the function signature.
exports.push('export type APIReturnType<T = any> = T extends (...args: any[]) => Promise<TypedResponse<infer O>> ? Promise<O> : never;');

exports.push(dedent`
declare module '$api' {
${proxy.exports.map((method) => dedent`
export function api_fetch(
url: RouteId,
options: FetchOptions<RouteParams, "${method}">
): APIReturnType<typeof import('${from}').${method}>;`
).join('\n')}
}`);
}


exports.push('export type RequestHandler = Kit.RequestHandler<RouteParams, RouteId>;');
exports.push(dedent`
interface TypedResponse<O> extends Response {
json(): Promise<O>;
}
type Methods = "GET" | "POST" | "PUT" | "DELETE" | "HEAD" | "OPTIONS" | "PATCH";
type FetchOptions<
Params = never,
Method extends Methods = Methods
> = Parameters<typeof fetch>[1] & {
params: Params,
method: Method
}
`)
}

if (route.leaf?.server || route.layout?.server || route.endpoint) {
Expand Down Expand Up @@ -502,21 +537,21 @@ function process_node(node, outdir, is_page, proxies, all_pages_have_load = true
*/
function ensureProxies(node, proxies) {
if (node.server && !proxies.server) {
proxies.server = createProxy(node.server, true);
proxies.server = createProxy(node.server, 'page.server');
}

if (node.universal && !proxies.universal) {
proxies.universal = createProxy(node.universal, false);
proxies.universal = createProxy(node.universal, 'page');
}
}

/**
* @param {string} file_path
* @param {boolean} is_server
* @param {"page.server" | "page" | "endpoint"} type
* @returns {Proxy}
*/
function createProxy(file_path, is_server) {
const proxy = tweak_types(fs.readFileSync(file_path, 'utf8'), is_server);
function createProxy(file_path, type) {
const proxy = tweak_types(fs.readFileSync(file_path, 'utf8'), type);
if (proxy) {
return {
...proxy,
Expand Down Expand Up @@ -600,11 +635,24 @@ function generate_params_type(params, outdir, config) {

/**
* @param {string} content
* @param {boolean} is_server
* @param {"page" | "page.server" | "endpoint"} type
* @returns {Omit<NonNullable<Proxy>, 'file_name'> | null}
*/
export function tweak_types(content, is_server) {
const names = new Set(is_server ? ['load', 'actions'] : ['load']);
export function tweak_types(content, type) {
/** @type {Set<string>} */
let names;

switch (type) {
case 'page.server':
names = new Set(['load', 'actions']);
break;
case 'page':
names = new Set(['load']);
break;
case 'endpoint':
names = new Set(['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'PATCH']);
break;
}

try {
let modified = false;
Expand Down Expand Up @@ -754,7 +802,7 @@ export function tweak_types(content, is_server) {
modified = true;
}
} else if (
is_server &&
type === 'page.server' &&
ts.isIdentifier(declaration.name) &&
declaration.name?.text === 'actions' &&
declaration.initializer
Expand Down
18 changes: 9 additions & 9 deletions packages/kit/src/core/sync/write_types/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ test('Rewrites types for a TypeScript module', () => {
};
`;

const rewritten = tweak_types(source, false);
const rewritten = tweak_types(source, 'page');

expect(rewritten?.exports).toEqual(['load']);
assert.equal(
Expand All @@ -83,7 +83,7 @@ test('Rewrites types for a TypeScript module without param', () => {
};
`;

const rewritten = tweak_types(source, false);
const rewritten = tweak_types(source, 'page');

expect(rewritten?.exports).toEqual(['load']);
assert.equal(
Expand All @@ -109,7 +109,7 @@ test('Rewrites types for a TypeScript module without param and jsdoc without typ
};
`;

const rewritten = tweak_types(source, false);
const rewritten = tweak_types(source, 'page');

expect(rewritten?.exports).toEqual(['load']);
assert.equal(
Expand All @@ -136,7 +136,7 @@ test('Rewrites types for a JavaScript module with `function`', () => {
};
`;

const rewritten = tweak_types(source, false);
const rewritten = tweak_types(source, 'page');

expect(rewritten?.exports).toEqual(['load']);
assert.equal(
Expand All @@ -163,7 +163,7 @@ test('Rewrites types for a JavaScript module with `const`', () => {
};
`;

const rewritten = tweak_types(source, false);
const rewritten = tweak_types(source, 'page');

expect(rewritten?.exports).toEqual(['load']);
assert.equal(
Expand All @@ -190,7 +190,7 @@ test('Appends @ts-nocheck after @ts-check', () => {
};
`;

const rewritten = tweak_types(source, false);
const rewritten = tweak_types(source, 'page');

expect(rewritten?.exports).toEqual(['load']);
assert.equal(
Expand Down Expand Up @@ -219,7 +219,7 @@ test('Rewrites action types for a JavaScript module', () => {
}
`;

const rewritten = tweak_types(source, true);
const rewritten = tweak_types(source, 'page.server');

expect(rewritten?.exports).toEqual(['actions']);
assert.equal(
Expand Down Expand Up @@ -248,7 +248,7 @@ test('Rewrites action types for a TypeScript module', () => {
}
`;

const rewritten = tweak_types(source, true);
const rewritten = tweak_types(source, 'page.server');

expect(rewritten?.exports).toEqual(['actions']);
assert.equal(
Expand Down Expand Up @@ -281,7 +281,7 @@ test('Leaves satisfies operator untouched', () => {
} satisfies Actions
`;

const rewritten = tweak_types(source, true);
const rewritten = tweak_types(source, 'page.server');

expect(rewritten?.exports).toEqual(['load', 'actions']);
assert.equal(rewritten?.modified, false);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { json } from '@sveltejs/kit';

interface TypedResponse<A> extends Response {
json(): Promise<A>;
}

// A typed json wrapper (this could probably just replace `json`
function typed_json<A>(data: A): TypedResponse<A> {
return json(data)
}

export async function GET(): Promise<TypedResponse<{ foo: string }>> {
return typed_json({ foo: 'bar' })
}