sveltekit-python-vercel 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,116 @@
1
+ import { $ as run$, cd as cd$, which, path, chalk, } from "zx";
2
+ const get_pyServerEndpointAsString = (app_url, serve = false) => `
3
+ const handle = (method) => (async ({ request, fetch, url }) => {
4
+ const headers = new Headers()
5
+ headers.append('content-type', request.headers.get('content-type'));
6
+ headers.append('accept', request.headers.get('accept'));
7
+
8
+ let fullURL;
9
+
10
+ if (${serve}) {
11
+ fullURL = new URL(url.pathname, new URL('${app_url}')) + url.search;
12
+ } else {
13
+ fullURL = new URL('/api' + url.pathname, url.origin) + url.search;
14
+ }
15
+
16
+ console.log(\`PY: Reached python endpoint of \${method} \${fullURL}\`)
17
+ let requestBody = await request.clone().text();
18
+ console.log(\`PY: Body: \${requestBody}\`);
19
+
20
+ if (method === 'GET') {
21
+ requestBody = null;
22
+ }
23
+
24
+ return fetch(fullURL, { headers, method, body: requestBody, signal: request.signal, duplex: 'half' });
25
+ });
26
+
27
+ export const GET = handle('GET');
28
+ export const POST = handle('POST');
29
+ export const PATCH = handle('PATCH');
30
+ export const PUT = handle('PUT');
31
+ export const DELETE = handle('DELETE');
32
+ `;
33
+ export async function sveltekit_python_vercel(opts = {}) {
34
+ const child_processes = [];
35
+ async function kill_all_process() {
36
+ for (const ps of child_processes) {
37
+ await ps.kill();
38
+ await ps.exitCode;
39
+ }
40
+ }
41
+ let sveltekit_url;
42
+ const plugin_python_serve = {
43
+ name: "vite-plugin-sveltekit-python-serve",
44
+ apply: "serve",
45
+ async closeBundle() {
46
+ await kill_all_process();
47
+ },
48
+ async configureServer({ config }) {
49
+ const packagelocation = path.join(config.root, "node_modules", "sveltekit-python-vercel", "esm/src/vite");
50
+ // copy asll +server.py files to package directory
51
+ run$.verbose = false;
52
+ run$.env.PYTHONDONTWRITEBYTECODE = "1";
53
+ cd$(packagelocation);
54
+ const python_path = opts.python_path ?? (await which("python3"));
55
+ const host = opts.host ?? "0.0.0.0";
56
+ const port = opts.port ?? 8000;
57
+ const local_process = run$ `${python_path} -m sveltekit_python_vercel.serve --host ${host} --port ${port} --root ${config.root}`;
58
+ child_processes.push(local_process);
59
+ sveltekit_url ??= new URL(`http://${host}:${port}`);
60
+ cd$(config.root);
61
+ // local_process.quiet(); // let it be loud for now
62
+ local_process.nothrow();
63
+ local_process.stderr.on("data", (s) => {
64
+ console.log(s.toString().trimEnd()); //Logs stderr always and all of stdout if 'log': True
65
+ });
66
+ local_process.stderr.on("error", (s) => {
67
+ console.error(chalk.red("Error: Python Serve Failed"));
68
+ console.error(s.toString().trimEnd());
69
+ });
70
+ local_process.stdout.on("error", (s) => {
71
+ console.error(chalk.red("Error: Python Serve Failed"));
72
+ console.error(s.toString().trimEnd());
73
+ });
74
+ },
75
+ };
76
+ const plugin_python_build = {
77
+ name: "vite-plugin-sveltekit_python-build",
78
+ apply: "build",
79
+ async configResolved(config) {
80
+ console.log("PY: ROOT PATH: " + config.root);
81
+ },
82
+ };
83
+ const plugin_py_server_endpoint_serve = {
84
+ name: "vite-plugin-sveltekit_python-server-endpoint",
85
+ apply: "serve",
86
+ transform(src, id) {
87
+ // console.log("Transform function called for", id); // Add this line
88
+ if (/\.py$/.test(id)) {
89
+ if (sveltekit_url === undefined)
90
+ throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
91
+ return {
92
+ code: get_pyServerEndpointAsString(sveltekit_url, true),
93
+ map: null, // provide source map if available
94
+ };
95
+ }
96
+ },
97
+ };
98
+ const plugin_py_server_endpoint_build = {
99
+ name: "vite-plugin-sveltekit_python-server-endpoint",
100
+ apply: "build",
101
+ transform(src, id) {
102
+ if (/\.py$/.test(id)) {
103
+ return {
104
+ code: get_pyServerEndpointAsString(new URL("http://localhost"), false),
105
+ map: null,
106
+ };
107
+ }
108
+ },
109
+ };
110
+ return [
111
+ plugin_python_serve,
112
+ plugin_python_build,
113
+ plugin_py_server_endpoint_serve,
114
+ plugin_py_server_endpoint_build,
115
+ ];
116
+ }
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env zx --install
2
+
3
+ import {$} from "zx";
4
+
5
+ const python_path = await $`which python3`;
6
+ await $`${python_path} ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/build.py --root . --packagedir ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel`;
@@ -0,0 +1,120 @@
1
+ import argparse
2
+ import glob
3
+ import json
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path, PurePosixPath
8
+
9
+ parser = argparse.ArgumentParser(description="Run SvelteKit Python Deployment")
10
+ parser.add_argument("--root", default=".", help="Root directory of the SvelteKit project")
11
+ parser.add_argument("--packagedir", default=None, help="Directory of the sveltekit-python-vercel package")
12
+ args = parser.parse_args()
13
+
14
+ root_dir = Path(args.root).absolute()
15
+
16
+ func_dir = root_dir / ".vercel" / "output" / "functions" / "api" / "index.func"
17
+ func_dir.mkdir(parents=True, exist_ok=True)
18
+
19
+ if args.packagedir:
20
+ shutil.copy(Path(args.packagedir).absolute() / "deploy.py", func_dir / "index.py")
21
+
22
+ # find all +server.py routes and copy them into the .func directory
23
+ manifest = []
24
+
25
+ for module_path in glob.glob(str(root_dir / "src/routes/**/+server.py"), recursive=True):
26
+ rel = Path(module_path).absolute().relative_to(root_dir / "src/routes")
27
+
28
+ # replace square brackets with curly brackets
29
+ rel = Path(str(rel).replace("[", "{").replace("]", "}"))
30
+
31
+ # remove any SvteleKit groups from the URL
32
+ parts = [
33
+ p for p in PurePosixPath(rel).parts
34
+ if not (p.startswith("(") and p.endswith(")"))
35
+ ]
36
+ rel = Path(*parts)
37
+
38
+ target_dir = func_dir / rel.parent
39
+ target_dir.mkdir(parents=True, exist_ok=True)
40
+
41
+ shutil.copy(module_path, target_dir / rel.name)
42
+
43
+ if not (target_dir / "__init__.py").exists():
44
+ (target_dir / "__init__.py").touch()
45
+
46
+ # build the API route
47
+ parent = PurePosixPath(rel).parent
48
+ if str(parent) == ".":
49
+ api_route = "/api"
50
+ else:
51
+ api_route = "/api/" + str(parent)
52
+
53
+ manifest.append({"file": str(PurePosixPath(rel)), "route": api_route})
54
+ print(f"PYTHON ENDPOINT: {module_path} → {api_route}")
55
+
56
+ (func_dir / "_manifest.json").write_text(json.dumps(manifest, indent=2))
57
+
58
+ # bundle the dependency file here
59
+ dep_files = ["requirements.txt", "pyproject.toml", "uv.lock", "Pipfile", "Pipfile.lock"]
60
+ found_dep = False
61
+ for dep_file in dep_files:
62
+ src = root_dir / dep_file
63
+ if src.exists():
64
+ shutil.copy(src, func_dir / dep_file)
65
+ print(f"PYTHON ENDPOINT: Bundled {dep_file}")
66
+ found_dep = True
67
+
68
+ if not found_dep:
69
+ (func_dir / "requirements.txt").write_text("fastapi\nuvicorn\n")
70
+ print("PYTHON ENDPOINT: No dependency file found, created minimal requirements.txt")
71
+
72
+ # pre-install Python packages into _deps/ so they are available in the lambda env
73
+ dep_dir = func_dir / "_deps"
74
+ dep_dir.mkdir(exist_ok=True)
75
+ pip_cmd = [sys.executable, "-m", "pip", "install", "--target", str(dep_dir), "--quiet"]
76
+ if (func_dir / "requirements.txt").exists():
77
+ pip_cmd += ["-r", str(func_dir / "requirements.txt")]
78
+ subprocess.run(pip_cmd, check=True)
79
+ print("PYTHON ENDPOINT: Installed deps from requirements.txt")
80
+ elif (func_dir / "pyproject.toml").exists():
81
+ # extract dependencies from pyproject.toml
82
+ try:
83
+ import tomllib
84
+ except ImportError:
85
+ import tomli as tomllib # type: ignore
86
+ with open(func_dir / "pyproject.toml", "rb") as _f:
87
+ _pyproject = tomllib.load(_f)
88
+ _deps_list = _pyproject.get("project", {}).get("dependencies", [])
89
+ if _deps_list:
90
+ subprocess.run(pip_cmd + _deps_list, check=True)
91
+ print(f"PYTHON ENDPOINT: Installed {len(_deps_list)} deps from pyproject.toml")
92
+
93
+ # python3.12 is the AWS Lambda runtime string
94
+ vc_config = {"runtime": "python3.12", "handler": "index.handler"}
95
+ (func_dir / ".vc-config.json").write_text(json.dumps(vc_config, indent=2))
96
+ print("PYTHON ENDPOINT: Created .vc-config.json")
97
+
98
+ # patch the SvelteKit config.json that the endpoints are rerouted to python
99
+ config_path = root_dir / ".vercel" / "output" / "config.json"
100
+ if config_path.exists():
101
+ config = json.loads(config_path.read_text())
102
+ routes = config.get("routes", [])
103
+
104
+ python_route = {"src": "^/api(/.*)?$", "dest": "api/index"}
105
+
106
+ # routes /api/* to python before SvelteKit catches it
107
+ fs_idx = next(
108
+ (i for i, r in enumerate(routes) if r.get("handle") == "filesystem"),
109
+ 0,
110
+ )
111
+ routes.insert(fs_idx, python_route)
112
+ config["routes"] = routes
113
+
114
+ config_path.write_text(json.dumps(config, indent=2))
115
+ print("PYTHON ENDPOINT: Patched .vercel/output/config.json")
116
+ else:
117
+ print(
118
+ "WARNING: .vercel/output/config.json not found. "
119
+ "Make sure to run `vite build` before this script."
120
+ )
@@ -0,0 +1,141 @@
1
+ import asyncio
2
+ import base64
3
+ import importlib.util
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+ from urllib.parse import urlsplit
8
+
9
+ _base = Path(__file__).parent
10
+ _deps = _base / "_deps"
11
+ if _deps.exists() and str(_deps) not in sys.path:
12
+ sys.path.insert(0, str(_deps))
13
+ if str(_base) not in sys.path:
14
+ sys.path.insert(0, str(_base))
15
+
16
+ from fastapi import FastAPI, Request
17
+ from fastapi.responses import JSONResponse
18
+
19
+ app = FastAPI()
20
+
21
+
22
+ def _load_module(file_path: Path):
23
+ spec = importlib.util.spec_from_file_location("_route_module", file_path)
24
+ mod = importlib.util.module_from_spec(spec)
25
+ spec.loader.exec_module(mod)
26
+ return mod
27
+
28
+
29
+ _manifest_path = _base / "_manifest.json"
30
+ if _manifest_path.exists():
31
+ for _entry in json.loads(_manifest_path.read_text()):
32
+ _mod = _load_module(_base / _entry["file"])
33
+ _route = _entry["route"]
34
+
35
+ for _method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
36
+ _has_upper = hasattr(_mod, _method)
37
+ _has_lower = hasattr(_mod, _method.lower())
38
+
39
+ if _has_upper and _has_lower:
40
+ raise Exception(
41
+ f"Duplicate method {_method} and {_method.lower()} in {_route}"
42
+ )
43
+ elif _has_upper:
44
+ app.add_api_route(_route, getattr(_mod, _method), methods=[_method])
45
+ print(f"PYTHON ENDPOINT: Registered {_method} {_route}")
46
+ elif _has_lower:
47
+ app.add_api_route(_route, getattr(_mod, _method.lower()), methods=[_method])
48
+ print(f"PYTHON ENDPOINT: Registered {_method} {_route}")
49
+
50
+
51
+ @app.exception_handler(Exception)
52
+ async def _exception_handler(request: Request, exc: Exception):
53
+ return JSONResponse(status_code=500, content={"error": str(exc)})
54
+
55
+
56
+ async def _dispatch(scope: dict, body: bytes) -> tuple[int, dict, bytes]:
57
+ """Run FastAPI for one HTTP request and collect the response."""
58
+ queue: asyncio.Queue = asyncio.Queue()
59
+ queue.put_nowait({"type": "http.request", "body": body, "more_body": False})
60
+
61
+ status = 500
62
+ resp_headers: dict = {}
63
+ resp_body = b""
64
+
65
+ async def receive():
66
+ return await queue.get()
67
+
68
+ async def send(message):
69
+ nonlocal status, resp_body
70
+ if message["type"] == "http.response.start":
71
+ status = message["status"]
72
+ for k, v in message.get("headers", []):
73
+ if isinstance(k, bytes):
74
+ k = k.decode()
75
+ if isinstance(v, bytes):
76
+ v = v.decode()
77
+ k = k.lower()
78
+ if k in resp_headers:
79
+ existing = resp_headers[k]
80
+ resp_headers[k] = (existing if isinstance(existing, list) else [existing]) + [v]
81
+ else:
82
+ resp_headers[k] = v
83
+ elif message["type"] == "http.response.body":
84
+ resp_body += message.get("body", b"")
85
+
86
+ await app(scope, receive, send)
87
+ return status, resp_headers, resp_body
88
+
89
+
90
+ def handler(event, context):
91
+ payload = json.loads(event.get("body") or "{}")
92
+
93
+ # parse the path and query string
94
+ parsed = urlsplit(payload.get("path", "/"))
95
+ path = parsed.path or "/"
96
+ query = (parsed.query or "").encode()
97
+
98
+ # Build ASGI-style header list
99
+ raw_headers: dict = payload.get("headers") or {}
100
+ headers_list = []
101
+ host = payload.get("host", "localhost")
102
+ scheme = "https"
103
+ for k, v in raw_headers.items():
104
+ k_lower = k.lower()
105
+ if k_lower == "host":
106
+ host = v
107
+ if k_lower == "x-forwarded-proto":
108
+ scheme = v
109
+ headers_list.append((k_lower.encode(), str(v).encode()))
110
+
111
+ scope = {
112
+ "type": "http",
113
+ "http_version": "1.1",
114
+ "method": payload.get("method", "GET").upper(),
115
+ "path": path,
116
+ "raw_path": path.encode(),
117
+ "query_string": query,
118
+ "root_path": "",
119
+ "headers": headers_list,
120
+ "server": (host, 443 if scheme == "https" else 80),
121
+ "client": (raw_headers.get("x-real-ip", "127.0.0.1"), 0),
122
+ "scheme": scheme,
123
+ }
124
+
125
+ body = payload.get("body") or b""
126
+ if payload.get("encoding") == "base64":
127
+ body = base64.b64decode(body)
128
+ elif isinstance(body, str):
129
+ body = body.encode()
130
+
131
+ status, resp_headers, resp_body = asyncio.run(_dispatch(scope, body))
132
+
133
+ result: dict = {"statusCode": status, "headers": resp_headers}
134
+ if resp_body:
135
+ try:
136
+ result["body"] = resp_body.decode("utf-8")
137
+ except UnicodeDecodeError:
138
+ result["body"] = base64.b64encode(resp_body).decode()
139
+ result["encoding"] = "base64"
140
+
141
+ return result
@@ -0,0 +1,82 @@
1
+ import argparse
2
+ import glob
3
+ import importlib
4
+ import importlib.util
5
+ import shutil
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ import uvicorn
9
+ from fastapi import FastAPI
10
+
11
+ parser = argparse.ArgumentParser(description="Run Sveltekit Python Server")
12
+ parser.add_argument("--host", default="0.0.0.0", help="Server hostname")
13
+ parser.add_argument("--port", type=int, default=8000, help="Server port")
14
+ parser.add_argument("--root", default=".", help="Directory where the API is located")
15
+ args = parser.parse_args()
16
+
17
+ app = FastAPI()
18
+
19
+ root_dir = Path(args.root).absolute()
20
+
21
+ api_dir = Path("./sveltekit_python_vercel").absolute()
22
+
23
+ route_dir = root_dir.joinpath("src/routes")
24
+
25
+ watch_modules = [] # list of modules to watch for changes
26
+
27
+ for module_path in glob.glob(
28
+ route_dir.joinpath("**/+server.py").as_posix(), recursive=True
29
+ ):
30
+ abs_module_path = Path(module_path).absolute()
31
+
32
+ watch_modules.append(abs_module_path.parent.as_posix())
33
+
34
+ api_route = api_dir.joinpath(abs_module_path.relative_to(root_dir / "src/routes"))
35
+
36
+ if not api_route.parent.exists():
37
+ api_route.parent.mkdir(parents=True)
38
+
39
+ # copy module path to api_route
40
+ shutil.copy(module_path, api_route.parent)
41
+
42
+ module_name = api_route.stem
43
+
44
+ spec = importlib.util.spec_from_file_location(module_name, api_route)
45
+ mod = importlib.util.module_from_spec(spec)
46
+ spec.loader.exec_module(mod)
47
+
48
+ # Get the relative path of the module from the API directory
49
+ rel_path = api_route.relative_to(api_dir)
50
+
51
+ # Convert the relative path to a string and remove the file extension
52
+ api_path = f"/{rel_path.parent}"
53
+
54
+ # Replace square brackets with curly brackets
55
+ api_path = api_path.replace("[", "{").replace("]", "}")
56
+
57
+ # remove any groups from the URL
58
+ api_path = str(PurePosixPath(*[part for part in PurePosixPath(api_path).parts if not part.startswith("(") and not part.endswith(")")]))
59
+
60
+ # Add endpoints
61
+ for method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
62
+ # Check for duplicate methods
63
+ if hasattr(mod, method) and hasattr(mod, method.lower()):
64
+ raise Exception(
65
+ f"Duplicate method {method} and {method.lower()} in {api_route}"
66
+ )
67
+ elif hasattr(mod, method):
68
+ app.add_api_route(api_path, getattr(mod, method), methods=[method])
69
+ elif hasattr(mod, method.lower()):
70
+ app.add_api_route(api_path, getattr(mod, method.lower()), methods=[method])
71
+
72
+
73
+ if __name__ == "__main__":
74
+ uvicorn.run(
75
+ "sveltekit_python_vercel.serve:app",
76
+ host=args.host,
77
+ port=args.port,
78
+ log_level="info",
79
+ reload=True,
80
+ reload_includes=[*set(watch_modules)],
81
+ reload_excludes=[api_dir.as_posix()],
82
+ )
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "module": "./esm/mod.js",
3
3
  "types": "./types/mod.d.ts",
4
4
  "name": "sveltekit-python-vercel",
5
- "version": "1.0.2",
5
+ "version": "1.0.3",
6
6
  "description": "Write Sveltekit server endpoints in Python and seamlessly deploy to Vercel",
7
7
  "repository": {
8
8
  "type": "git",
@@ -0,0 +1,8 @@
1
+ import { type Plugin } from "vite";
2
+ export interface SveltekitPythonOptions {
3
+ python_path?: string;
4
+ log?: boolean;
5
+ host?: string;
6
+ port?: number;
7
+ }
8
+ export declare function sveltekit_python_vercel(opts?: SveltekitPythonOptions): Promise<Plugin[]>;