sveltekit-python-vercel 0.4.1 → 1.0.3-beta.53c34c5
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.
- package/README.md +98 -115
- package/esm/src/vite/mod.js +91 -39
- package/esm/src/vite/sveltekit_python_vercel/__init__.py +3 -0
- package/esm/src/vite/sveltekit_python_vercel/build.py +105 -36
- package/esm/src/vite/sveltekit_python_vercel/deploy.py +151 -46
- package/esm/src/vite/sveltekit_python_vercel/load_runtime.py +91 -0
- package/esm/src/vite/sveltekit_python_vercel/routes.py +39 -0
- package/esm/src/vite/sveltekit_python_vercel/serve.py +61 -33
- package/esm/src/vite/sveltekit_python_vercel/test_load_runtime.py +119 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
<p align="middle">
|
|
2
2
|
<img width="100" alt="image" src="https://user-images.githubusercontent.com/20548516/218344678-d41f4c4a-6b1b-48cc-8553-2b9fbe2169d6.png"/>
|
|
3
|
-
<img width="100" alt="image" src="https://
|
|
4
|
-
<img width="100" alt="image" src="https://
|
|
3
|
+
<img width="100" alt="image" src="https://avatars.githubusercontent.com/u/1525981?s=200&v=4"/>
|
|
4
|
+
<img width="100" alt="image" src="https://avatars.githubusercontent.com/u/14985020?s=200&v=4"/>
|
|
5
5
|
</p>
|
|
6
6
|
|
|
7
7
|
# sveltekit-python-vercel
|
|
@@ -22,7 +22,8 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
|
|
|
22
22
|
## Current Features
|
|
23
23
|
|
|
24
24
|
- Write `+server.py` files nearly the same way you would write `+server.js` files
|
|
25
|
-
-
|
|
25
|
+
- Write server `load` functions in `+page.server.py` and `+layout.server.py`
|
|
26
|
+
- Deploy automatically to Vercel Serverless (Python 3.12 runtime)
|
|
26
27
|
|
|
27
28
|
## Installing
|
|
28
29
|
|
|
@@ -36,17 +37,15 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
|
|
|
36
37
|
import { sveltekit } from "@sveltejs/kit/vite";
|
|
37
38
|
import { sveltekit_python_vercel } from "sveltekit-python-vercel/vite";
|
|
38
39
|
|
|
39
|
-
export default defineConfig(
|
|
40
|
-
|
|
41
|
-
plugins: [sveltekit_python_vercel(), sveltekit()],
|
|
42
|
-
};
|
|
40
|
+
export default defineConfig({
|
|
41
|
+
plugins: [sveltekit(), ...(await sveltekit_python_vercel())],
|
|
43
42
|
});
|
|
44
43
|
```
|
|
45
44
|
|
|
46
45
|
- Update your `svelte.config.js`:
|
|
47
46
|
|
|
48
47
|
```javascript
|
|
49
|
-
import adapter from "@sveltejs/adapter-vercel";
|
|
48
|
+
import adapter from "@sveltejs/adapter-vercel";
|
|
50
49
|
import { vitePreprocess } from "@sveltejs/kit/vite";
|
|
51
50
|
|
|
52
51
|
/** @type {import('@sveltejs/kit').Config} */
|
|
@@ -63,23 +62,12 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
|
|
|
63
62
|
|
|
64
63
|
- Update your `vercel.json`
|
|
65
64
|
|
|
66
|
-
- The build command
|
|
67
|
-
-
|
|
65
|
+
- The build command first runs `vite build` (which generates `.vercel/output/` via the SvelteKit adapter), then runs our script to write the Python function into that same output directory and patch the routing config.
|
|
66
|
+
- No `routes` or `functions` keys are needed — routing is handled automatically via the [Vercel Build Output API](https://vercel.com/docs/build-output-api/v3).
|
|
68
67
|
|
|
69
68
|
```json
|
|
70
69
|
{
|
|
71
|
-
"buildCommand": "node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs
|
|
72
|
-
"functions": {
|
|
73
|
-
"api/**/*.py": {
|
|
74
|
-
"runtime": "@vercel/python@3.0.7"
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
"routes": [
|
|
78
|
-
{
|
|
79
|
-
"src": "/api/(.*)",
|
|
80
|
-
"dest": "api/index.py"
|
|
81
|
-
}
|
|
82
|
-
]
|
|
70
|
+
"buildCommand": "vite build; node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs"
|
|
83
71
|
}
|
|
84
72
|
```
|
|
85
73
|
|
|
@@ -87,112 +75,59 @@ Write Python endpoints in [SvelteKit](https://kit.svelte.dev/) and seamlessly de
|
|
|
87
75
|
|
|
88
76
|
## Testing Locally
|
|
89
77
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
- Run `poetry init` to create a new virtual environment, and follow the steps. Or simply create a `pyproject.toml` like the one below.
|
|
93
|
-
|
|
94
|
-
```toml
|
|
95
|
-
[tool.poetry]
|
|
96
|
-
name = "sveltekit-python-example"
|
|
97
|
-
version = "0.1.0"
|
|
98
|
-
description = ""
|
|
99
|
-
authors = ["Your Name <email@gmail.com>"]
|
|
100
|
-
readme = "README.md"
|
|
101
|
-
|
|
102
|
-
[tool.poetry.dependencies]
|
|
103
|
-
python = "^3.9"
|
|
104
|
-
fastapi = "^0.95.2"
|
|
105
|
-
uvicorn = "^0.22.0"
|
|
106
|
-
|
|
107
|
-
[tool.poetry.group.dev.dependencies]
|
|
108
|
-
watchfiles = "^0.19.0"
|
|
109
|
-
|
|
110
|
-
[build-system]
|
|
111
|
-
requires = ["poetry-core"]
|
|
112
|
-
build-backend = "poetry.core.masonry.api"
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
- Required packages are python3.9 (that is what Vercel's runtime uses), `fastapi`, and `uvicorn`.
|
|
116
|
-
- Install whatever other dependencies you need from pypi using `poetry add package-name`
|
|
78
|
+
[uv](https://docs.astral.sh/uv/) is recommended for managing your Python environment.
|
|
117
79
|
|
|
118
|
-
-
|
|
119
|
-
-
|
|
120
|
-
|
|
80
|
+
- Run `uv init --python 3.12` to create a `pyproject.toml` pinned to Python 3.12 (the same version Vercel's runtime uses).
|
|
81
|
+
- Add the required packages: `uv add fastapi uvicorn`
|
|
82
|
+
- Add any other dependencies you need: `uv add numpy pandas ...`
|
|
83
|
+
- Run your dev server inside uv:
|
|
84
|
+
- `uv run pnpm dev`
|
|
85
|
+
- You should see both the usual SvelteKit server start and the uvicorn server (by default on `http://0.0.0.0:8000`) in the console.
|
|
121
86
|
|
|
122
87
|
## Deploying to Vercel
|
|
123
88
|
|
|
124
|
-
|
|
89
|
+
Just push to your repository — no extra steps required.
|
|
125
90
|
|
|
126
|
-
-
|
|
127
|
-
1. `
|
|
128
|
-
2. `
|
|
129
|
-
-
|
|
130
|
-
|
|
131
|
-
Note:
|
|
132
|
-
|
|
133
|
-
- To make this a bit smoother, you can add a script to you `package.json`:
|
|
134
|
-
```json
|
|
135
|
-
"scripts": {
|
|
136
|
-
...
|
|
137
|
-
"py-update": "poetry export -f requirements.txt --output requirements.txt --without-hashes; node ./node_modules/sveltekit-python-vercel/esm/src/vite/sveltekit_python_vercel/bin.mjs"
|
|
138
|
-
}
|
|
139
|
-
```
|
|
140
|
-
- and then just run `pnpm py-update`
|
|
91
|
+
- The `buildCommand` in `vercel.json` handles everything automatically:
|
|
92
|
+
1. `vite build` runs the SvelteKit build and writes `.vercel/output/` via `@sveltejs/adapter-vercel`
|
|
93
|
+
2. `bin.mjs` then writes your Python endpoints into `.vercel/output/functions/` using the [Build Output API](https://vercel.com/docs/build-output-api/v3) and patches the routing config
|
|
94
|
+
- Your `+server.py` files and dependency declarations (`requirements.txt`, `pyproject.toml`, `Pipfile`, etc.) are bundled automatically — there is no need to commit an `/api` folder or manually generate a `requirements.txt`.
|
|
95
|
+
- Python packages are pre-installed into the function bundle at build time using `pip install --target`, so they are available in Vercel's raw Python 3.12 Lambda environment without any extra configuration.
|
|
141
96
|
|
|
142
97
|
## Example
|
|
143
98
|
|
|
144
99
|
- Frontend: `/src/routes/py/+page.svelte`
|
|
145
100
|
|
|
146
101
|
```html
|
|
147
|
-
<script
|
|
148
|
-
let a = 0;
|
|
149
|
-
let b = 0;
|
|
150
|
-
let total = 0;
|
|
102
|
+
<script>
|
|
103
|
+
let a = $state(0);
|
|
104
|
+
let b = $state(0);
|
|
105
|
+
let total = $state(0);
|
|
151
106
|
|
|
152
107
|
async function pyAddPost() {
|
|
153
|
-
const
|
|
108
|
+
const res = await fetch("/py", {
|
|
154
109
|
method: "POST",
|
|
155
110
|
body: JSON.stringify({ a, b }),
|
|
156
|
-
headers: {
|
|
157
|
-
"content-type": "application/json",
|
|
158
|
-
},
|
|
111
|
+
headers: { "content-type": "application/json" },
|
|
159
112
|
});
|
|
160
|
-
|
|
161
|
-
total = res.sum;
|
|
113
|
+
total = (await res.json()).sum;
|
|
162
114
|
}
|
|
163
115
|
|
|
164
116
|
async function pyAddGet() {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
headers: {
|
|
168
|
-
"content-type": "application/json",
|
|
169
|
-
},
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
let res = await response.json();
|
|
173
|
-
total = res.sum;
|
|
117
|
+
const res = await fetch(`/py?a=${a}&b=${b}`);
|
|
118
|
+
total = (await res.json()).sum;
|
|
174
119
|
}
|
|
175
120
|
</script>
|
|
176
121
|
|
|
177
|
-
<h1>
|
|
178
|
-
|
|
179
|
-
<
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<
|
|
186
|
-
|
|
187
|
-
<br />
|
|
188
|
-
|
|
189
|
-
<h3>GET Example</h3>
|
|
190
|
-
<form>
|
|
191
|
-
<input type="number" name="a" placeholder="Number 1" bind:value="{a}" />
|
|
192
|
-
<input type="number" name="b" placeholder="Number 2" bind:value="{b}" />
|
|
193
|
-
<button on:click|preventDefault="{pyAddGet}">Add</button>
|
|
194
|
-
</form>
|
|
195
|
-
<h4>Total: {total}</h4>
|
|
122
|
+
<h1>SvelteKit page with a Python backend</h1>
|
|
123
|
+
|
|
124
|
+
<label>a: <input type="number" bind:value={a} /></label>
|
|
125
|
+
<label>b: <input type="number" bind:value={b} /></label>
|
|
126
|
+
|
|
127
|
+
<button type="button" onclick={pyAddPost}>POST</button>
|
|
128
|
+
<button type="button" onclick={pyAddGet}>GET</button>
|
|
129
|
+
|
|
130
|
+
<p>Total: {total}</p>
|
|
196
131
|
```
|
|
197
132
|
|
|
198
133
|
- Backend: `/src/routes/py/+server.py`
|
|
@@ -212,17 +147,65 @@ Note:
|
|
|
212
147
|
|
|
213
148
|
async def GET(a: float, b: float):
|
|
214
149
|
return {"sum": a + b}
|
|
215
|
-
|
|
216
150
|
```
|
|
217
151
|
|
|
218
152
|
### Backend Caveats
|
|
219
153
|
|
|
220
|
-
|
|
154
|
+
- `GET` endpoints receive query parameters directly as function arguments. Type annotations are used for coercion (e.g. `a: float` parses `?a=3` as `3.0`).
|
|
155
|
+
- All other HTTP methods receive the request body as JSON. The recommended pattern is a Pydantic model as the single argument — FastAPI handles validation and parsing automatically.
|
|
156
|
+
|
|
157
|
+
### Python load functions
|
|
158
|
+
|
|
159
|
+
Server-side `load` functions work in `+page.server.py` and `+layout.server.py`. No extra Python package install is required — the runtime is bundled automatically like `+server.py`.
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
async def load(event):
|
|
163
|
+
if event.params["id"] == "secret":
|
|
164
|
+
return ("redirect", 307, "/demo/public")
|
|
165
|
+
|
|
166
|
+
if event.params["id"] not in ("public", "1", "2"):
|
|
167
|
+
return ("error", 404, "Not found")
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
"title": f"Item {event.params['id']}",
|
|
171
|
+
"parent_theme": event.parent.theme if event.parent else None,
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Errors and redirects can also use injected helpers (no import needed):
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
async def load(event):
|
|
179
|
+
if not event.cookies.get("session"):
|
|
180
|
+
redirect(307, "/login")
|
|
181
|
+
return {"ok": True}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Available on `event`: `params`, `url`, `route`, `parent` (layout data), `data` (from a sibling universal load), `cookies`.
|
|
185
|
+
|
|
186
|
+
**Current limitations:** no `event.fetch`, `setHeaders`, `depends`, or page options (`prerender`, etc.) in `.py` files yet.
|
|
187
|
+
|
|
188
|
+
## npm channels
|
|
189
|
+
|
|
190
|
+
| Tag | When it updates | Install |
|
|
191
|
+
|-----|-----------------|---------|
|
|
192
|
+
| `latest` | GitHub Release created | `pnpm add -D sveltekit-python-vercel` |
|
|
193
|
+
| `beta` | Every push to `main` | `pnpm add -D sveltekit-python-vercel@beta` |
|
|
194
|
+
| `pr-<n>` | Open/update PR `#n` (same-repo only) | `pnpm add -D sveltekit-python-vercel@pr-15` |
|
|
195
|
+
|
|
196
|
+
Beta versions look like `1.0.3-beta.abc1234` (main) or `1.0.3-beta.pr15.abc1234` (PRs).
|
|
197
|
+
|
|
198
|
+
All publishes run through `.github/workflows/publish.yml` because npm trusted publishing only allows one workflow filename per package.
|
|
199
|
+
|
|
200
|
+
### Developing the package locally
|
|
201
|
+
|
|
202
|
+
If you work on this repo and a consumer app side by side (e.g. `test-skpv-deploy`):
|
|
221
203
|
|
|
222
|
-
|
|
223
|
-
|
|
204
|
+
1. Build: `deno run -A dnt.ts $(npm view sveltekit-python-vercel version)` (or any version string)
|
|
205
|
+
2. In the consumer: `pnpm add -D sveltekit-python-vercel@link:../sveltekit-python-vercel/npm`
|
|
206
|
+
3. Rebuild and restart the consumer dev server after library changes
|
|
224
207
|
|
|
225
|
-
|
|
208
|
+
Commit `^x.y.z` or `@beta` in the consumer for Vercel — `link:` only works on your machine.
|
|
226
209
|
|
|
227
210
|
## Fork of `sveltekit-modal`
|
|
228
211
|
|
|
@@ -231,8 +214,8 @@ Check out the awesome [sveltekit-modal](https://github.com/semicognitive/sveltek
|
|
|
231
214
|
## Possible future plans
|
|
232
215
|
|
|
233
216
|
- [X] Add hot reloading in dev mode
|
|
234
|
-
- [
|
|
235
|
-
- [
|
|
217
|
+
- [X] Generate endpoints automatically during build (via Vercel Build Output API)
|
|
218
|
+
- [X] Auto-bundle requirements.txt / pyproject.toml / Pipfile at build time
|
|
236
219
|
- [ ] Add form actions
|
|
237
|
-
- [
|
|
238
|
-
- [ ] Add helper functions to automatically call API endpoints in project
|
|
220
|
+
- [X] Add load functions
|
|
221
|
+
- [ ] Add helper functions to automatically call API endpoints in project
|
package/esm/src/vite/mod.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { loadEnv } from "vite";
|
|
2
1
|
import { $ as run$, cd as cd$, which, path, chalk, } from "zx";
|
|
3
2
|
const get_pyServerEndpointAsString = (app_url, serve = false) => `
|
|
4
3
|
const handle = (method) => (async ({ request, fetch, url }) => {
|
|
@@ -9,9 +8,9 @@ const get_pyServerEndpointAsString = (app_url, serve = false) => `
|
|
|
9
8
|
let fullURL;
|
|
10
9
|
|
|
11
10
|
if (${serve}) {
|
|
12
|
-
fullURL = new URL(url.pathname, new URL('${app_url}')) + url.search;
|
|
13
|
-
} else {
|
|
14
11
|
fullURL = new URL('/api' + url.pathname, new URL('${app_url}')) + url.search;
|
|
12
|
+
} else {
|
|
13
|
+
fullURL = new URL('/api' + url.pathname, url.origin) + url.search;
|
|
15
14
|
}
|
|
16
15
|
|
|
17
16
|
console.log(\`PY: Reached python endpoint of \${method} \${fullURL}\`)
|
|
@@ -31,6 +30,72 @@ const get_pyServerEndpointAsString = (app_url, serve = false) => `
|
|
|
31
30
|
export const PUT = handle('PUT');
|
|
32
31
|
export const DELETE = handle('DELETE');
|
|
33
32
|
`;
|
|
33
|
+
function getLoadRouteTemplate(id) {
|
|
34
|
+
const marker = "/src/routes/";
|
|
35
|
+
const idx = id.indexOf(marker);
|
|
36
|
+
if (idx === -1) {
|
|
37
|
+
throw new Error(`Cannot derive load route from ${id}`);
|
|
38
|
+
}
|
|
39
|
+
let routePart = id.slice(idx + marker.length);
|
|
40
|
+
routePart = routePart.replace(/\/\+(?:page|layout)\.server\.py$/, "");
|
|
41
|
+
if (!routePart) {
|
|
42
|
+
return "/api/_load";
|
|
43
|
+
}
|
|
44
|
+
const segments = routePart
|
|
45
|
+
.split("/")
|
|
46
|
+
.filter((part) => !(part.startsWith("(") && part.endsWith(")")))
|
|
47
|
+
.map((part) => part.replace(/^\[(.+)\]$/, "{$1}"));
|
|
48
|
+
return `/api/_load/${segments.join("/")}`;
|
|
49
|
+
}
|
|
50
|
+
const get_pyLoadAsString = (loadRouteTemplate, app_url, serve = false) => `
|
|
51
|
+
import { error, redirect } from '@sveltejs/kit';
|
|
52
|
+
|
|
53
|
+
const LOAD_ROUTE_TEMPLATE = ${JSON.stringify(loadRouteTemplate)};
|
|
54
|
+
|
|
55
|
+
function buildLoadPath(params) {
|
|
56
|
+
let path = LOAD_ROUTE_TEMPLATE;
|
|
57
|
+
for (const [key, value] of Object.entries(params)) {
|
|
58
|
+
path = path.replaceAll(\`{\${key}}\`, encodeURIComponent(String(value)));
|
|
59
|
+
}
|
|
60
|
+
return path;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function load(event) {
|
|
64
|
+
const parent = event.parent ? await event.parent() : undefined;
|
|
65
|
+
|
|
66
|
+
const body = JSON.stringify({
|
|
67
|
+
params: event.params,
|
|
68
|
+
route: { id: event.route.id },
|
|
69
|
+
url: event.url.href,
|
|
70
|
+
parent,
|
|
71
|
+
data: event.data ?? undefined,
|
|
72
|
+
cookies: Object.fromEntries(event.cookies.getAll().map((c) => [c.name, c.value])),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const apiPath = buildLoadPath(event.params);
|
|
76
|
+
const fullURL = ${serve}
|
|
77
|
+
? new URL(apiPath, new URL('${app_url}'))
|
|
78
|
+
: new URL(apiPath, event.url.origin);
|
|
79
|
+
|
|
80
|
+
const res = await event.fetch(fullURL, {
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'content-type': 'application/json' },
|
|
83
|
+
body,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const result = await res.json();
|
|
87
|
+
|
|
88
|
+
if (result.type === 'redirect') redirect(result.status, result.location);
|
|
89
|
+
if (result.type === 'error') error(result.status, result.body);
|
|
90
|
+
return result.data;
|
|
91
|
+
}
|
|
92
|
+
`;
|
|
93
|
+
function isPyServerFile(id) {
|
|
94
|
+
return /\+server\.py$/.test(id);
|
|
95
|
+
}
|
|
96
|
+
function isPyLoadFile(id) {
|
|
97
|
+
return /\+(?:page|layout)\.server\.py$/.test(id);
|
|
98
|
+
}
|
|
34
99
|
export async function sveltekit_python_vercel(opts = {}) {
|
|
35
100
|
const child_processes = [];
|
|
36
101
|
async function kill_all_process() {
|
|
@@ -48,7 +113,6 @@ export async function sveltekit_python_vercel(opts = {}) {
|
|
|
48
113
|
},
|
|
49
114
|
async configureServer({ config }) {
|
|
50
115
|
const packagelocation = path.join(config.root, "node_modules", "sveltekit-python-vercel", "esm/src/vite");
|
|
51
|
-
// copy asll +server.py files to package directory
|
|
52
116
|
run$.verbose = false;
|
|
53
117
|
run$.env.PYTHONDONTWRITEBYTECODE = "1";
|
|
54
118
|
cd$(packagelocation);
|
|
@@ -59,10 +123,9 @@ export async function sveltekit_python_vercel(opts = {}) {
|
|
|
59
123
|
child_processes.push(local_process);
|
|
60
124
|
sveltekit_url ??= new URL(`http://${host}:${port}`);
|
|
61
125
|
cd$(config.root);
|
|
62
|
-
// local_process.quiet(); // let it be loud for now
|
|
63
126
|
local_process.nothrow();
|
|
64
127
|
local_process.stderr.on("data", (s) => {
|
|
65
|
-
console.log(s.toString().trimEnd());
|
|
128
|
+
console.log(s.toString().trimEnd());
|
|
66
129
|
});
|
|
67
130
|
local_process.stderr.on("error", (s) => {
|
|
68
131
|
console.error(chalk.red("Error: Python Serve Failed"));
|
|
@@ -78,53 +141,42 @@ export async function sveltekit_python_vercel(opts = {}) {
|
|
|
78
141
|
name: "vite-plugin-sveltekit_python-build",
|
|
79
142
|
apply: "build",
|
|
80
143
|
async configResolved(config) {
|
|
81
|
-
console.log("PY: BUILD DEBUG");
|
|
82
144
|
console.log("PY: ROOT PATH: " + config.root);
|
|
83
|
-
console.log("PY: LOADED VERCEL URL: " + loadEnv("", config.root, "").VERCEL_URL);
|
|
84
|
-
const packagelocation = path.join(config.root, "node_modules", "sveltekit-python-vercel", "esm/src/vite");
|
|
85
|
-
console.log("PY: PACKAGE LOCATION: " + packagelocation);
|
|
86
|
-
const python_path = opts.python_path ?? (await which("python3"));
|
|
87
|
-
await run$ `cd ${packagelocation}`;
|
|
88
|
-
await run$ `${python_path} ${packagelocation}/sveltekit_python_vercel/build.py --root ${config.root}`;
|
|
89
|
-
// check if env var starts with http
|
|
90
|
-
let httpPrefix = "";
|
|
91
|
-
if (!loadEnv("", config.root, "").VERCEL_URL.startsWith("http")) {
|
|
92
|
-
httpPrefix = "https://";
|
|
93
|
-
}
|
|
94
|
-
const api_url = path.join(httpPrefix + loadEnv("", config.root, "").VERCEL_URL);
|
|
95
|
-
// get current Vercel deploy URL
|
|
96
|
-
sveltekit_url = new URL(api_url);
|
|
97
|
-
console.log("PY: Build API URL: " + sveltekit_url.toString());
|
|
98
145
|
},
|
|
99
146
|
};
|
|
147
|
+
const transformPyFile = (id, serve, app_url) => {
|
|
148
|
+
if (!/\.py$/.test(id))
|
|
149
|
+
return undefined;
|
|
150
|
+
if (isPyServerFile(id)) {
|
|
151
|
+
return {
|
|
152
|
+
code: get_pyServerEndpointAsString(app_url, serve),
|
|
153
|
+
map: null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (isPyLoadFile(id)) {
|
|
157
|
+
const loadRouteTemplate = getLoadRouteTemplate(id);
|
|
158
|
+
return {
|
|
159
|
+
code: get_pyLoadAsString(loadRouteTemplate, app_url, serve),
|
|
160
|
+
map: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
return undefined;
|
|
164
|
+
};
|
|
100
165
|
const plugin_py_server_endpoint_serve = {
|
|
101
166
|
name: "vite-plugin-sveltekit_python-server-endpoint",
|
|
102
167
|
apply: "serve",
|
|
103
168
|
transform(src, id) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (sveltekit_url === undefined)
|
|
107
|
-
throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
|
|
108
|
-
return {
|
|
109
|
-
code: get_pyServerEndpointAsString(sveltekit_url, true),
|
|
110
|
-
map: null, // provide source map if available
|
|
111
|
-
};
|
|
169
|
+
if (sveltekit_url === undefined) {
|
|
170
|
+
throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
|
|
112
171
|
}
|
|
172
|
+
return transformPyFile(id, true, sveltekit_url);
|
|
113
173
|
},
|
|
114
174
|
};
|
|
115
175
|
const plugin_py_server_endpoint_build = {
|
|
116
176
|
name: "vite-plugin-sveltekit_python-server-endpoint",
|
|
117
177
|
apply: "build",
|
|
118
178
|
transform(src, id) {
|
|
119
|
-
|
|
120
|
-
if (/\.py$/.test(id)) {
|
|
121
|
-
if (sveltekit_url === undefined)
|
|
122
|
-
throw new Error(`${plugin_python_serve.name} failed to produce a sveltekit_url`);
|
|
123
|
-
return {
|
|
124
|
-
code: get_pyServerEndpointAsString(sveltekit_url, false),
|
|
125
|
-
map: null, // provide source map if available
|
|
126
|
-
};
|
|
127
|
-
}
|
|
179
|
+
return transformPyFile(id, false, new URL("http://localhost"));
|
|
128
180
|
},
|
|
129
181
|
};
|
|
130
182
|
return [
|
|
@@ -1,45 +1,114 @@
|
|
|
1
1
|
import argparse
|
|
2
|
-
import shutil
|
|
3
2
|
import glob
|
|
4
|
-
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
5
7
|
from pathlib import Path, PurePosixPath
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
parser.add_argument("--packagedir", default=None, help="Root directory of Vercel project")
|
|
10
|
-
args = parser.parse_args()
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
10
|
+
from routes import api_route, load_route, rel_path_from_routes, route_parent
|
|
11
11
|
|
|
12
|
+
parser = argparse.ArgumentParser(description="Run SvelteKit Python Deployment")
|
|
13
|
+
parser.add_argument("--root", default=".", help="Root directory of the SvelteKit project")
|
|
14
|
+
parser.add_argument("--packagedir", default=None, help="Directory of the sveltekit-python-vercel package")
|
|
15
|
+
args = parser.parse_args()
|
|
12
16
|
|
|
13
17
|
root_dir = Path(args.root).absolute()
|
|
14
|
-
|
|
18
|
+
routes_root = root_dir / "src/routes"
|
|
19
|
+
|
|
20
|
+
func_dir = root_dir / ".vercel" / "output" / "functions" / "api" / "index.func"
|
|
21
|
+
func_dir.mkdir(parents=True, exist_ok=True)
|
|
15
22
|
|
|
16
|
-
if not api_dir.exists():
|
|
17
|
-
api_dir.mkdir()
|
|
18
|
-
|
|
19
23
|
if args.packagedir:
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
print(f"PYTHON ENDPOINT:
|
|
24
|
+
package_dir = Path(args.packagedir).absolute()
|
|
25
|
+
shutil.copy(package_dir / "deploy.py", func_dir / "index.py")
|
|
26
|
+
for helper in ("load_runtime.py", "routes.py"):
|
|
27
|
+
src = package_dir / helper
|
|
28
|
+
if src.exists():
|
|
29
|
+
shutil.copy(src, func_dir / helper)
|
|
30
|
+
|
|
31
|
+
manifest = []
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _bundle_route_module(module_path: str, *, kind: str) -> None:
|
|
35
|
+
abs_path = Path(module_path).absolute()
|
|
36
|
+
rel = rel_path_from_routes(abs_path, routes_root)
|
|
37
|
+
target_dir = func_dir / route_parent(rel)
|
|
38
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
shutil.copy(abs_path, target_dir / rel.name)
|
|
40
|
+
|
|
41
|
+
parent = route_parent(rel)
|
|
42
|
+
if kind == "load":
|
|
43
|
+
route = load_route(parent)
|
|
44
|
+
else:
|
|
45
|
+
route = api_route(parent)
|
|
46
|
+
|
|
47
|
+
entry = {"file": str(PurePosixPath(rel)), "route": route, "kind": kind}
|
|
48
|
+
manifest.append(entry)
|
|
49
|
+
print(f"PYTHON {'LOAD' if kind == 'load' else 'ENDPOINT'}: {module_path} → {route}")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
for module_path in glob.glob(str(routes_root / "**/+server.py"), recursive=True):
|
|
53
|
+
_bundle_route_module(module_path, kind="server")
|
|
54
|
+
|
|
55
|
+
for pattern in ("**/+page.server.py", "**/+layout.server.py"):
|
|
56
|
+
for module_path in glob.glob(str(routes_root / pattern), recursive=True):
|
|
57
|
+
_bundle_route_module(module_path, kind="load")
|
|
58
|
+
|
|
59
|
+
(func_dir / "_manifest.json").write_text(json.dumps(manifest, indent=2))
|
|
60
|
+
|
|
61
|
+
dep_files = ["requirements.txt", "pyproject.toml", "uv.lock", "Pipfile", "Pipfile.lock"]
|
|
62
|
+
found_dep = False
|
|
63
|
+
for dep_file in dep_files:
|
|
64
|
+
src = root_dir / dep_file
|
|
65
|
+
if src.exists():
|
|
66
|
+
shutil.copy(src, func_dir / dep_file)
|
|
67
|
+
print(f"PYTHON ENDPOINT: Bundled {dep_file}")
|
|
68
|
+
found_dep = True
|
|
69
|
+
|
|
70
|
+
if not found_dep:
|
|
71
|
+
(func_dir / "requirements.txt").write_text("fastapi\nuvicorn\n")
|
|
72
|
+
print("PYTHON ENDPOINT: No dependency file found, created minimal requirements.txt")
|
|
73
|
+
|
|
74
|
+
dep_dir = func_dir / "_deps"
|
|
75
|
+
dep_dir.mkdir(exist_ok=True)
|
|
76
|
+
pip_cmd = [sys.executable, "-m", "pip", "install", "--target", str(dep_dir), "--quiet"]
|
|
77
|
+
if (func_dir / "requirements.txt").exists():
|
|
78
|
+
pip_cmd += ["-r", str(func_dir / "requirements.txt")]
|
|
79
|
+
subprocess.run(pip_cmd, check=True)
|
|
80
|
+
print("PYTHON ENDPOINT: Installed deps from requirements.txt")
|
|
81
|
+
elif (func_dir / "pyproject.toml").exists():
|
|
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
|
+
vc_config = {"runtime": "python3.12", "handler": "index.handler"}
|
|
94
|
+
(func_dir / ".vc-config.json").write_text(json.dumps(vc_config, indent=2))
|
|
95
|
+
print("PYTHON ENDPOINT: Created .vc-config.json")
|
|
96
|
+
|
|
97
|
+
config_path = root_dir / ".vercel" / "output" / "config.json"
|
|
98
|
+
if config_path.exists():
|
|
99
|
+
config = json.loads(config_path.read_text())
|
|
100
|
+
routes = config.get("routes", [])
|
|
101
|
+
python_route = {"src": "^/api(/.*)?$", "dest": "api/index"}
|
|
102
|
+
fs_idx = next(
|
|
103
|
+
(i for i, r in enumerate(routes) if r.get("handle") == "filesystem"),
|
|
104
|
+
0,
|
|
105
|
+
)
|
|
106
|
+
routes.insert(fs_idx, python_route)
|
|
107
|
+
config["routes"] = routes
|
|
108
|
+
config_path.write_text(json.dumps(config, indent=2))
|
|
109
|
+
print("PYTHON ENDPOINT: Patched .vercel/output/config.json")
|
|
110
|
+
else:
|
|
111
|
+
print(
|
|
112
|
+
"WARNING: .vercel/output/config.json not found. "
|
|
113
|
+
"Make sure to run `vite build` before this script."
|
|
114
|
+
)
|
|
@@ -1,55 +1,160 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
3
|
import importlib.util
|
|
4
|
-
import
|
|
5
|
-
|
|
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))
|
|
6
15
|
|
|
7
16
|
from fastapi import FastAPI, Request
|
|
8
17
|
from fastapi.responses import JSONResponse
|
|
9
18
|
|
|
19
|
+
from load_runtime import run_load
|
|
20
|
+
from routes import route_registration_order
|
|
21
|
+
|
|
10
22
|
app = FastAPI()
|
|
11
23
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
24
|
+
|
|
25
|
+
def _load_module(file_path: Path):
|
|
26
|
+
spec = importlib.util.spec_from_file_location("_route_module", file_path)
|
|
27
|
+
mod = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(mod)
|
|
29
|
+
return mod
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _make_load_handler(mod):
|
|
33
|
+
async def handler(request: Request):
|
|
34
|
+
payload = await request.json()
|
|
35
|
+
return JSONResponse(await run_load(mod, payload))
|
|
36
|
+
|
|
37
|
+
return handler
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_manifest_path = _base / "_manifest.json"
|
|
41
|
+
if _manifest_path.exists():
|
|
42
|
+
_manifest = json.loads(_manifest_path.read_text())
|
|
43
|
+
_load_entries = [e for e in _manifest if e.get("kind") == "load"]
|
|
44
|
+
_server_entries = [e for e in _manifest if e.get("kind", "server") != "load"]
|
|
45
|
+
|
|
46
|
+
for _entry in sorted(_load_entries, key=lambda e: route_registration_order(e["route"])):
|
|
47
|
+
_mod = _load_module(_base / _entry["file"])
|
|
48
|
+
_route = _entry["route"]
|
|
49
|
+
app.add_api_route(_route, _make_load_handler(_mod), methods=["POST"])
|
|
50
|
+
print(f"PYTHON LOAD: Registered POST {_route}")
|
|
51
|
+
|
|
52
|
+
for _entry in _server_entries:
|
|
53
|
+
_mod = _load_module(_base / _entry["file"])
|
|
54
|
+
_route = _entry["route"]
|
|
55
|
+
|
|
56
|
+
for _method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
|
|
57
|
+
_has_upper = hasattr(_mod, _method)
|
|
58
|
+
_has_lower = hasattr(_mod, _method.lower())
|
|
59
|
+
|
|
60
|
+
if _has_upper and _has_lower:
|
|
61
|
+
raise Exception(
|
|
62
|
+
f"Duplicate method {_method} and {_method.lower()} in {_route}"
|
|
63
|
+
)
|
|
64
|
+
elif _has_upper:
|
|
65
|
+
app.add_api_route(_route, getattr(_mod, _method), methods=[_method])
|
|
66
|
+
print(f"PYTHON ENDPOINT: Registered {_method} {_route}")
|
|
67
|
+
elif _has_lower:
|
|
68
|
+
app.add_api_route(_route, getattr(_mod, _method.lower()), methods=[_method])
|
|
69
|
+
print(f"PYTHON ENDPOINT: Registered {_method} {_route}")
|
|
70
|
+
|
|
49
71
|
|
|
50
72
|
@app.exception_handler(Exception)
|
|
51
|
-
async def
|
|
52
|
-
return JSONResponse(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
73
|
+
async def _exception_handler(request: Request, exc: Exception):
|
|
74
|
+
return JSONResponse(status_code=500, content={"error": str(exc)})
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
async def _dispatch(scope: dict, body: bytes) -> tuple[int, dict, bytes]:
|
|
78
|
+
"""Run FastAPI for one HTTP request and collect the response."""
|
|
79
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
80
|
+
queue.put_nowait({"type": "http.request", "body": body, "more_body": False})
|
|
81
|
+
|
|
82
|
+
status = 500
|
|
83
|
+
resp_headers: dict = {}
|
|
84
|
+
resp_body = b""
|
|
85
|
+
|
|
86
|
+
async def receive():
|
|
87
|
+
return await queue.get()
|
|
88
|
+
|
|
89
|
+
async def send(message):
|
|
90
|
+
nonlocal status, resp_body
|
|
91
|
+
if message["type"] == "http.response.start":
|
|
92
|
+
status = message["status"]
|
|
93
|
+
for k, v in message.get("headers", []):
|
|
94
|
+
if isinstance(k, bytes):
|
|
95
|
+
k = k.decode()
|
|
96
|
+
if isinstance(v, bytes):
|
|
97
|
+
v = v.decode()
|
|
98
|
+
k = k.lower()
|
|
99
|
+
if k in resp_headers:
|
|
100
|
+
existing = resp_headers[k]
|
|
101
|
+
resp_headers[k] = (existing if isinstance(existing, list) else [existing]) + [v]
|
|
102
|
+
else:
|
|
103
|
+
resp_headers[k] = v
|
|
104
|
+
elif message["type"] == "http.response.body":
|
|
105
|
+
resp_body += message.get("body", b"")
|
|
106
|
+
|
|
107
|
+
await app(scope, receive, send)
|
|
108
|
+
return status, resp_headers, resp_body
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def handler(event, context):
|
|
112
|
+
payload = json.loads(event.get("body") or "{}")
|
|
113
|
+
|
|
114
|
+
parsed = urlsplit(payload.get("path", "/"))
|
|
115
|
+
path = parsed.path or "/"
|
|
116
|
+
query = (parsed.query or "").encode()
|
|
117
|
+
|
|
118
|
+
raw_headers: dict = payload.get("headers") or {}
|
|
119
|
+
headers_list = []
|
|
120
|
+
host = payload.get("host", "localhost")
|
|
121
|
+
scheme = "https"
|
|
122
|
+
for k, v in raw_headers.items():
|
|
123
|
+
k_lower = k.lower()
|
|
124
|
+
if k_lower == "host":
|
|
125
|
+
host = v
|
|
126
|
+
if k_lower == "x-forwarded-proto":
|
|
127
|
+
scheme = v
|
|
128
|
+
headers_list.append((k_lower.encode(), str(v).encode()))
|
|
129
|
+
|
|
130
|
+
scope = {
|
|
131
|
+
"type": "http",
|
|
132
|
+
"http_version": "1.1",
|
|
133
|
+
"method": payload.get("method", "GET").upper(),
|
|
134
|
+
"path": path,
|
|
135
|
+
"raw_path": path.encode(),
|
|
136
|
+
"query_string": query,
|
|
137
|
+
"root_path": "",
|
|
138
|
+
"headers": headers_list,
|
|
139
|
+
"server": (host, 443 if scheme == "https" else 80),
|
|
140
|
+
"client": (raw_headers.get("x-real-ip", "127.0.0.1"), 0),
|
|
141
|
+
"scheme": scheme,
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
body = payload.get("body") or b""
|
|
145
|
+
if payload.get("encoding") == "base64":
|
|
146
|
+
body = base64.b64decode(body)
|
|
147
|
+
elif isinstance(body, str):
|
|
148
|
+
body = body.encode()
|
|
149
|
+
|
|
150
|
+
status, resp_headers, resp_body = asyncio.run(_dispatch(scope, body))
|
|
151
|
+
|
|
152
|
+
result: dict = {"statusCode": status, "headers": resp_headers}
|
|
153
|
+
if resp_body:
|
|
154
|
+
try:
|
|
155
|
+
result["body"] = resp_body.decode("utf-8")
|
|
156
|
+
except UnicodeDecodeError:
|
|
157
|
+
result["body"] = base64.b64encode(resp_body).decode()
|
|
158
|
+
result["encoding"] = "base64"
|
|
159
|
+
|
|
160
|
+
return result
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
import types
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class SKPVError(Exception):
|
|
7
|
+
def __init__(self, status: int, body: Any):
|
|
8
|
+
self.status = status
|
|
9
|
+
self.body = body
|
|
10
|
+
super().__init__(body)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SKPVRedirect(Exception):
|
|
14
|
+
def __init__(self, status: int, location: str):
|
|
15
|
+
self.status = status
|
|
16
|
+
self.location = location
|
|
17
|
+
super().__init__(location)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def error(status: int, body: Any) -> None:
|
|
21
|
+
raise SKPVError(status, body)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def redirect(status: int, location: str) -> None:
|
|
25
|
+
raise SKPVRedirect(status, location)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def inject_load_helpers(mod) -> None:
|
|
29
|
+
if not hasattr(mod, "error"):
|
|
30
|
+
mod.error = error
|
|
31
|
+
if not hasattr(mod, "redirect"):
|
|
32
|
+
mod.redirect = redirect
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_namespace(value: Any) -> Any:
|
|
36
|
+
if value is None:
|
|
37
|
+
return None
|
|
38
|
+
if isinstance(value, dict):
|
|
39
|
+
ns = types.SimpleNamespace()
|
|
40
|
+
for key, val in value.items():
|
|
41
|
+
setattr(ns, key, _to_namespace(val))
|
|
42
|
+
return ns
|
|
43
|
+
if isinstance(value, list):
|
|
44
|
+
return [_to_namespace(item) for item in value]
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class _Cookies:
|
|
49
|
+
def __init__(self, data: Optional[dict]):
|
|
50
|
+
self._data = data or {}
|
|
51
|
+
|
|
52
|
+
def get(self, name: str, default: Optional[str] = None) -> Optional[str]:
|
|
53
|
+
return self._data.get(name, default)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def wrap_event(payload: dict) -> types.SimpleNamespace:
|
|
57
|
+
event = types.SimpleNamespace()
|
|
58
|
+
event.params = payload.get("params") or {}
|
|
59
|
+
event.route = _to_namespace(payload.get("route") or {})
|
|
60
|
+
event.url = payload.get("url", "")
|
|
61
|
+
event.parent = _to_namespace(payload.get("parent"))
|
|
62
|
+
event.data = payload.get("data")
|
|
63
|
+
event.cookies = _Cookies(payload.get("cookies"))
|
|
64
|
+
return event
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def parse_load_result(result: Any) -> dict:
|
|
68
|
+
if (
|
|
69
|
+
isinstance(result, tuple)
|
|
70
|
+
and len(result) == 3
|
|
71
|
+
and result[0] in ("error", "redirect")
|
|
72
|
+
):
|
|
73
|
+
kind, status, payload = result
|
|
74
|
+
if kind == "error":
|
|
75
|
+
return {"type": "error", "status": status, "body": payload}
|
|
76
|
+
return {"type": "redirect", "status": status, "location": payload}
|
|
77
|
+
return {"type": "data", "data": result}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def run_load(mod, payload: dict) -> dict:
|
|
81
|
+
inject_load_helpers(mod)
|
|
82
|
+
event = wrap_event(payload)
|
|
83
|
+
try:
|
|
84
|
+
result = mod.load(event)
|
|
85
|
+
if inspect.iscoroutine(result):
|
|
86
|
+
result = await result
|
|
87
|
+
return parse_load_result(result)
|
|
88
|
+
except SKPVRedirect as exc:
|
|
89
|
+
return {"type": "redirect", "status": exc.status, "location": exc.location}
|
|
90
|
+
except SKPVError as exc:
|
|
91
|
+
return {"type": "error", "status": exc.status, "body": exc.body}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from pathlib import Path, PurePosixPath
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def rel_path_from_routes(module_path: Path, routes_root: Path) -> PurePosixPath:
|
|
5
|
+
"""Route file path relative to src/routes, with groups stripped and [param] → {param}."""
|
|
6
|
+
rel = module_path.absolute().relative_to(routes_root)
|
|
7
|
+
rel = Path(str(rel).replace("[", "{").replace("]", "}"))
|
|
8
|
+
parts = [
|
|
9
|
+
p
|
|
10
|
+
for p in PurePosixPath(rel).parts
|
|
11
|
+
if not (p.startswith("(") and p.endswith(")"))
|
|
12
|
+
]
|
|
13
|
+
return PurePosixPath(*parts)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def route_parent(rel: PurePosixPath) -> PurePosixPath:
|
|
17
|
+
return PurePosixPath(rel).parent
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def api_route(rel_parent: PurePosixPath, *, prefix: str = "/api") -> str:
|
|
21
|
+
if str(rel_parent) == ".":
|
|
22
|
+
return prefix
|
|
23
|
+
return f"{prefix}/{rel_parent}"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def load_route(rel_parent: PurePosixPath) -> str:
|
|
27
|
+
return api_route(rel_parent, prefix="/api/_load")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def resolve_route_path(template: str, params: dict) -> str:
|
|
31
|
+
path = template
|
|
32
|
+
for key, value in params.items():
|
|
33
|
+
path = path.replace(f"{{{key}}}", str(value))
|
|
34
|
+
return path
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def route_registration_order(route: str) -> tuple:
|
|
38
|
+
"""Static routes before dynamic; longer paths before shorter."""
|
|
39
|
+
return ("{" in route, -len(route), route)
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
import argparse
|
|
2
2
|
import glob
|
|
3
|
-
import importlib
|
|
4
3
|
import importlib.util
|
|
5
4
|
import shutil
|
|
6
|
-
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
7
|
|
|
8
8
|
import uvicorn
|
|
9
|
-
from fastapi import FastAPI
|
|
9
|
+
from fastapi import FastAPI, Request
|
|
10
|
+
from fastapi.responses import JSONResponse
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).parent))
|
|
13
|
+
from load_runtime import run_load
|
|
14
|
+
from routes import (
|
|
15
|
+
api_route,
|
|
16
|
+
load_route,
|
|
17
|
+
rel_path_from_routes,
|
|
18
|
+
route_parent,
|
|
19
|
+
route_registration_order,
|
|
20
|
+
)
|
|
10
21
|
|
|
11
22
|
parser = argparse.ArgumentParser(description="Run Sveltekit Python Server")
|
|
12
23
|
parser.add_argument("--host", default="0.0.0.0", help="Server hostname")
|
|
@@ -17,58 +28,75 @@ args = parser.parse_args()
|
|
|
17
28
|
app = FastAPI()
|
|
18
29
|
|
|
19
30
|
root_dir = Path(args.root).absolute()
|
|
20
|
-
|
|
21
31
|
api_dir = Path("./sveltekit_python_vercel").absolute()
|
|
32
|
+
routes_root = root_dir / "src/routes"
|
|
33
|
+
watch_modules = []
|
|
22
34
|
|
|
23
|
-
route_dir = root_dir.joinpath("src/routes")
|
|
24
35
|
|
|
25
|
-
|
|
36
|
+
def _copy_module(module_path: Path) -> Path:
|
|
37
|
+
api_route_path = api_dir.joinpath(module_path.relative_to(routes_root))
|
|
38
|
+
api_route_path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
shutil.copy(module_path, api_route_path.parent / api_route_path.name)
|
|
40
|
+
return api_route_path.parent / api_route_path.name
|
|
26
41
|
|
|
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
42
|
|
|
34
|
-
|
|
43
|
+
def _load_module(api_route_path: Path):
|
|
44
|
+
spec = importlib.util.spec_from_file_location(api_route_path.stem, api_route_path)
|
|
45
|
+
mod = importlib.util.module_from_spec(spec)
|
|
46
|
+
spec.loader.exec_module(mod)
|
|
47
|
+
return mod
|
|
35
48
|
|
|
36
|
-
if not api_route.parent.exists():
|
|
37
|
-
api_route.parent.mkdir(parents=True)
|
|
38
49
|
|
|
39
|
-
|
|
40
|
-
|
|
50
|
+
def _make_load_handler(mod):
|
|
51
|
+
async def handler(request: Request):
|
|
52
|
+
payload = await request.json()
|
|
53
|
+
return JSONResponse(await run_load(mod, payload))
|
|
41
54
|
|
|
42
|
-
|
|
55
|
+
return handler
|
|
43
56
|
|
|
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
57
|
|
|
48
|
-
|
|
49
|
-
|
|
58
|
+
for module_path in glob.glob(routes_root.joinpath("**/+server.py").as_posix(), recursive=True):
|
|
59
|
+
abs_module_path = Path(module_path).absolute()
|
|
60
|
+
watch_modules.append(abs_module_path.parent.as_posix())
|
|
50
61
|
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
api_route_path = _copy_module(abs_module_path)
|
|
63
|
+
mod = _load_module(api_route_path)
|
|
53
64
|
|
|
54
|
-
|
|
55
|
-
api_path =
|
|
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(")")]))
|
|
65
|
+
rel = rel_path_from_routes(abs_module_path, routes_root)
|
|
66
|
+
api_path = api_route(rel.parent)
|
|
59
67
|
|
|
60
|
-
# Add endpoints
|
|
61
68
|
for method in ["GET", "POST", "PATCH", "PUT", "DELETE"]:
|
|
62
|
-
# Check for duplicate methods
|
|
63
69
|
if hasattr(mod, method) and hasattr(mod, method.lower()):
|
|
64
70
|
raise Exception(
|
|
65
|
-
f"Duplicate method {method} and {method.lower()} in {
|
|
71
|
+
f"Duplicate method {method} and {method.lower()} in {api_route_path}"
|
|
66
72
|
)
|
|
67
73
|
elif hasattr(mod, method):
|
|
68
74
|
app.add_api_route(api_path, getattr(mod, method), methods=[method])
|
|
69
75
|
elif hasattr(mod, method.lower()):
|
|
70
76
|
app.add_api_route(api_path, getattr(mod, method.lower()), methods=[method])
|
|
71
77
|
|
|
78
|
+
load_entries = []
|
|
79
|
+
for pattern in ("**/+page.server.py", "**/+layout.server.py"):
|
|
80
|
+
for module_path in glob.glob(routes_root.joinpath(pattern).as_posix(), recursive=True):
|
|
81
|
+
abs_module_path = Path(module_path).absolute()
|
|
82
|
+
watch_modules.append(abs_module_path.parent.as_posix())
|
|
83
|
+
|
|
84
|
+
api_route_path = _copy_module(abs_module_path)
|
|
85
|
+
mod = _load_module(api_route_path)
|
|
86
|
+
|
|
87
|
+
if not hasattr(mod, "load"):
|
|
88
|
+
raise Exception(f"Missing load function in {abs_module_path}")
|
|
89
|
+
|
|
90
|
+
rel = rel_path_from_routes(abs_module_path, routes_root)
|
|
91
|
+
load_path = load_route(route_parent(rel))
|
|
92
|
+
load_entries.append((load_path, mod, abs_module_path))
|
|
93
|
+
|
|
94
|
+
for load_path, mod, abs_module_path in sorted(
|
|
95
|
+
load_entries, key=lambda entry: route_registration_order(entry[0])
|
|
96
|
+
):
|
|
97
|
+
app.add_api_route(load_path, _make_load_handler(mod), methods=["POST"])
|
|
98
|
+
print(f"PYTHON LOAD: Registered POST {load_path} ← {abs_module_path}")
|
|
99
|
+
|
|
72
100
|
|
|
73
101
|
if __name__ == "__main__":
|
|
74
102
|
uvicorn.run(
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import types
|
|
2
|
+
import unittest
|
|
3
|
+
|
|
4
|
+
from load_runtime import (
|
|
5
|
+
SKPVError,
|
|
6
|
+
inject_load_helpers,
|
|
7
|
+
parse_load_result,
|
|
8
|
+
redirect,
|
|
9
|
+
run_load,
|
|
10
|
+
wrap_event,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ParseLoadResultTests(unittest.TestCase):
|
|
15
|
+
def test_data(self):
|
|
16
|
+
self.assertEqual(
|
|
17
|
+
parse_load_result({"ok": True}),
|
|
18
|
+
{"type": "data", "data": {"ok": True}},
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
def test_error_tuple(self):
|
|
22
|
+
self.assertEqual(
|
|
23
|
+
parse_load_result(("error", 404, "Not found")),
|
|
24
|
+
{"type": "error", "status": 404, "body": "Not found"},
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def test_redirect_tuple(self):
|
|
28
|
+
self.assertEqual(
|
|
29
|
+
parse_load_result(("redirect", 307, "/login")),
|
|
30
|
+
{"type": "redirect", "status": 307, "location": "/login"},
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
def test_tuple_data_not_misread(self):
|
|
34
|
+
self.assertEqual(
|
|
35
|
+
parse_load_result(("items", 1, 2)),
|
|
36
|
+
{"type": "data", "data": ("items", 1, 2)},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class RunLoadTests(unittest.IsolatedAsyncioTestCase):
|
|
41
|
+
async def test_injected_error(self):
|
|
42
|
+
mod = types.ModuleType("test")
|
|
43
|
+
exec(
|
|
44
|
+
compile(
|
|
45
|
+
"async def load(event):\n error(403, 'denied')\n",
|
|
46
|
+
"<test>",
|
|
47
|
+
"exec",
|
|
48
|
+
),
|
|
49
|
+
mod.__dict__,
|
|
50
|
+
)
|
|
51
|
+
result = await run_load(mod, {"params": {}})
|
|
52
|
+
self.assertEqual(result["type"], "error")
|
|
53
|
+
self.assertEqual(result["status"], 403)
|
|
54
|
+
|
|
55
|
+
async def test_injected_redirect(self):
|
|
56
|
+
mod = types.ModuleType("test")
|
|
57
|
+
exec(
|
|
58
|
+
compile(
|
|
59
|
+
"async def load(event):\n redirect(307, '/home')\n",
|
|
60
|
+
"<test>",
|
|
61
|
+
"exec",
|
|
62
|
+
),
|
|
63
|
+
mod.__dict__,
|
|
64
|
+
)
|
|
65
|
+
result = await run_load(mod, {"params": {}})
|
|
66
|
+
self.assertEqual(result["type"], "redirect")
|
|
67
|
+
self.assertEqual(result["location"], "/home")
|
|
68
|
+
|
|
69
|
+
async def test_return_tuple_error(self):
|
|
70
|
+
mod = types.ModuleType("test")
|
|
71
|
+
|
|
72
|
+
async def load(event):
|
|
73
|
+
return ("error", 404, "missing")
|
|
74
|
+
|
|
75
|
+
mod.load = load
|
|
76
|
+
result = await run_load(mod, {"params": {}})
|
|
77
|
+
self.assertEqual(result, {"type": "error", "status": 404, "body": "missing"})
|
|
78
|
+
|
|
79
|
+
async def test_wrap_event_parent_and_cookies(self):
|
|
80
|
+
mod = types.ModuleType("test")
|
|
81
|
+
captured = {}
|
|
82
|
+
|
|
83
|
+
async def load(event):
|
|
84
|
+
captured["theme"] = event.parent.theme
|
|
85
|
+
captured["session"] = event.cookies.get("session")
|
|
86
|
+
return {"ok": True}
|
|
87
|
+
|
|
88
|
+
mod.load = load
|
|
89
|
+
await run_load(
|
|
90
|
+
mod,
|
|
91
|
+
{
|
|
92
|
+
"params": {"id": "1"},
|
|
93
|
+
"parent": {"theme": "dark"},
|
|
94
|
+
"cookies": {"session": "abc"},
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
self.assertEqual(captured["theme"], "dark")
|
|
98
|
+
self.assertEqual(captured["session"], "abc")
|
|
99
|
+
|
|
100
|
+
async def test_user_error_helper_not_overwritten(self):
|
|
101
|
+
mod = types.ModuleType("test")
|
|
102
|
+
|
|
103
|
+
def custom_error(status, body):
|
|
104
|
+
raise SKPVError(418, "teapot")
|
|
105
|
+
|
|
106
|
+
mod.error = custom_error
|
|
107
|
+
|
|
108
|
+
async def load(event):
|
|
109
|
+
custom_error(404, "ignored")
|
|
110
|
+
|
|
111
|
+
mod.load = load
|
|
112
|
+
inject_load_helpers(mod)
|
|
113
|
+
with self.assertRaises(SKPVError) as ctx:
|
|
114
|
+
await mod.load(wrap_event({}))
|
|
115
|
+
self.assertEqual(ctx.exception.status, 418)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
unittest.main()
|
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": "
|
|
5
|
+
"version": "1.0.3-beta.53c34c5",
|
|
6
6
|
"description": "Write Sveltekit server endpoints in Python and seamlessly deploy to Vercel",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|