@podosoft/podokit 1.3.0 → 1.4.1
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 +1 -1
- package/dist/add.d.ts +10 -1
- package/dist/add.js +20 -7
- package/dist/api-paths.d.ts +34 -0
- package/dist/api-paths.js +103 -0
- package/dist/deploy-compose-profile.d.ts +4 -0
- package/dist/deploy-compose-profile.js +13 -2
- package/dist/deploy-compose-render.d.ts +1 -1
- package/dist/deploy-compose-render.js +23 -7
- package/dist/deploy-compose.js +2 -1
- package/dist/deploy-profile.d.ts +3 -0
- package/dist/deploy-profile.js +12 -4
- package/dist/deploy-render.js +54 -8
- package/dist/deploy.js +21 -1
- package/dist/dev.d.ts +14 -0
- package/dist/dev.js +152 -10
- package/dist/index.js +15 -2
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +4 -2
- package/dist/lockfile.d.ts +3 -0
- package/dist/lockfile.js +11 -0
- package/dist/module-api-paths.d.ts +52 -0
- package/dist/module-api-paths.js +189 -0
- package/dist/remove.d.ts +3 -0
- package/dist/remove.js +12 -3
- package/dist/templates/fullstack/AGENTS.md +27 -5
- package/dist/templates/fullstack/README.md +28 -11
- package/dist/templates/fullstack/apps/api/package.json +2 -2
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +25 -0
- package/dist/templates/fullstack/apps/api/src/app.extensions.ts +7 -0
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +20 -1
- package/dist/templates/fullstack/apps/api/src/app.ts +11 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +3 -0
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.spec.ts +48 -2
- package/dist/templates/fullstack/apps/api/src/config/redis.connection.ts +45 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.spec.ts +46 -0
- package/dist/templates/fullstack/apps/api/src/config/server-options.ts +48 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +1 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +9 -1
- package/dist/templates/fullstack/apps/api/src/core/services.spec.ts +196 -0
- package/dist/templates/fullstack/apps/api/src/core/services.ts +181 -2
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +26 -1
- package/dist/templates/fullstack/apps/api/src/database/database.ts +52 -11
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.spec.ts +188 -0
- package/dist/templates/fullstack/apps/api/src/database/pool-guard.ts +151 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +70 -0
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.spec.ts +28 -4
- package/dist/templates/fullstack/apps/api/src/health/health.plugin.ts +10 -2
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -1
- package/dist/templates/fullstack/apps/web/components.json +3 -1
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/error.html +73 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/index.ts +7 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/ui/textarea/textarea.svelte +22 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +276 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +194 -9
- package/dist/templates/fullstack/apps/web/src/lib/server/body-limits.ts +15 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-headers.ts +12 -0
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +10 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-elysia-endpoint/SKILL.md +5 -0
- package/dist/templates/fullstack/dot-claude/skills/podokit-sveltekit-route/SKILL.md +7 -1
- package/dist/templates/fullstack/dot-devcontainer/devcontainer.json +1 -0
- package/dist/templates/fullstack/{dot-env.docker → dot-env.docker.example} +7 -4
- package/dist/templates/fullstack/dot-env.example +10 -0
- package/dist/templates/fullstack/dot-gitignore +1 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +2 -1
- package/dist/templates/fullstack/infra/k3s/README.md +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/admin-sessions/admin-sessions.migration.spec.ts +32 -3
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/api/src/migrations/1792060000000-IndexSessionCreatedAt.ts +19 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +27 -9
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/asset-paths.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts +125 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts +107 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +10 -10
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/object-response.ts +12 -0
- package/dist/templates/modules/admin-dashboard/module.manifest.json +3 -2
- package/dist/templates/modules/audit-log/module.manifest.json +1 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.spec.ts +26 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +17 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.service.ts +8 -4
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +2 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.spec.ts +114 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/database-outage.ts +50 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.spec.ts +4 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/db.ts +11 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance-command.ts +5 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/maintenance.ts +14 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.spec.ts +136 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/postgres-pool.ts +40 -0
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -2
- package/dist/templates/modules/auth/module.manifest.json +0 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +121 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +108 -10
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/jobs.module.ts +3 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/queue.ts +31 -3
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/events-redis.module.ts +2 -4
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.spec.ts +224 -0
- package/dist/templates/modules/events-redis/files/apps/api/src/events-provider-redis/redis-event.bus.ts +124 -35
- package/dist/templates/modules/events-redis/module.manifest.json +4 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.routes.ts +136 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.spec.ts +636 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/chunked-uploads.ts +673 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.spec.ts +10 -4
- package/dist/templates/modules/file-upload/files/apps/api/src/files/files.module.ts +37 -8
- package/dist/templates/modules/file-upload/files/apps/api/src/files/upload-config.ts +99 -0
- package/dist/templates/modules/file-upload/files/apps/api/src/migrations/1792100000000-InitFileUploadSessions.ts +49 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.test.ts +456 -0
- package/dist/templates/modules/file-upload/files/apps/web/src/lib/uploads.ts +356 -0
- package/dist/templates/modules/file-upload/files/tests/api/chunked-uploads.api.spec.ts +153 -0
- package/dist/templates/modules/file-upload/module.manifest.json +34 -6
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/jobs-local.module.ts +1 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +23 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +30 -20
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/download-url.ts +20 -0
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.spec.ts +79 -12
- package/dist/templates/modules/object-storage-local/files/apps/api/src/storage-local/storage-local.module.ts +16 -1
- package/dist/templates/modules/object-storage-local/module.manifest.json +11 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.spec.ts +115 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-multipart.ts +171 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.spec.ts +222 -9
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-object.store.ts +55 -19
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.spec.ts +71 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/s3-signer.ts +98 -0
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage-demo.spec.ts +6 -3
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.config.ts +7 -2
- package/dist/templates/modules/object-storage-s3/files/apps/api/src/storage/storage.service.ts +187 -44
- package/dist/templates/modules/object-storage-s3/module.manifest.json +10 -2
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.module.ts +11 -22
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.spec.ts +113 -0
- package/dist/templates/modules/rate-limit/files/apps/api/src/rate-limit/rate-limit.routes.ts +122 -0
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +24 -0
- package/dist/templates/modules/rate-limit/module.manifest.json +7 -0
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.spec.ts +14 -1
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis-cache.store.ts +9 -12
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.module.ts +16 -4
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.spec.ts +233 -5
- package/dist/templates/modules/redis/files/apps/api/src/redis/redis.service.ts +134 -32
- package/dist/templates/modules/redis/module.manifest.json +4 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/event-stream.ts +11 -4
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.spec.ts +33 -0
- package/dist/templates/modules/sse/files/apps/api/src/events/events.service.ts +7 -0
- package/dist/templates/todo/AGENTS.md +19 -4
- package/dist/templates/todo/README.md +27 -12
- package/dist/update.d.ts +5 -0
- package/dist/update.js +35 -0
- package/package.json +2 -2
- package/dist/templates/modules/auth/files/apps/api/scripts/run-migrations.mjs +0 -14
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-validation.ts +0 -13
- package/dist/templates/modules/auth/files/apps/api/src/auth/skip-schema-validation.ts +0 -5
|
@@ -19,6 +19,12 @@ Review these fields before planning:
|
|
|
19
19
|
- `exposure`: Ingress or NodePort. Ingress routes `/` to web and routes only the
|
|
20
20
|
exact paths in `webSocketPaths` directly to API. The list is empty by default;
|
|
21
21
|
root, prefixes, patterns, queries, encoded separators, and traversal are invalid.
|
|
22
|
+
`apiPaths` lists static prefixes under `/api/` (such as `/api/files/uploads/`)
|
|
23
|
+
for bodies above the web `BODY_SIZE_LIMIT`; a second Ingress with a Traefik
|
|
24
|
+
`stripPrefix` Middleware sends them to API without `/api`. It needs a
|
|
25
|
+
Traefik-served IngressClass, and `/api/auth` is rejected. `podo deploy init`
|
|
26
|
+
and `podo add` fill in the paths installed modules declare (file-upload:
|
|
27
|
+
`/api/files/uploads/`); keep them.
|
|
22
28
|
- `workloads`: API/web replicas and resources plus an optional worker
|
|
23
29
|
- `dependencies`: PostgreSQL, Redis, and object storage mode, image, Secret name, and storage
|
|
24
30
|
- `secrets`: API/optional web Secret names and required key names, plus image-pull Secret
|
|
@@ -61,9 +67,10 @@ The fields that differ from the Kubernetes driver:
|
|
|
61
67
|
|
|
62
68
|
- `target`: Docker context, endpoint fingerprint, Compose project name
|
|
63
69
|
- `exposure`: `bindAddress` and `port`, plus an exact `webSocketPaths` allowlist,
|
|
64
|
-
`trustedProxyCidrs`, and `gatewayImage`. With
|
|
65
|
-
published port. With paths, an internal gateway owns
|
|
66
|
-
paths to API,
|
|
70
|
+
an `apiPaths` prefix list, `trustedProxyCidrs`, and `gatewayImage`. With both
|
|
71
|
+
lists empty, web owns the published port. With paths, an internal gateway owns
|
|
72
|
+
it, sends exact WebSocket paths to API, sends `apiPaths` prefixes to API with
|
|
73
|
+
`/api` removed, and sends every other request to web. Forwarding headers are trusted
|
|
67
74
|
only from the direct proxy CIDRs in `trustedProxyCidrs`; leave it empty without an
|
|
68
75
|
external proxy. The bind address defaults to `127.0.0.1`, because the expected
|
|
69
76
|
front door is a trusted reverse proxy — binding every interface publishes the app
|
|
@@ -19,8 +19,13 @@ description: Use when adding or changing an Elysia REST endpoint in apps/api. Co
|
|
|
19
19
|
`{ success: false, error: { code, message, statusCode, path, timestamp } }`.
|
|
20
20
|
4. APIs are authenticated by default when auth is installed. Register an
|
|
21
21
|
explicit `public`, `session`, `admin`, or `api-key` policy for every route.
|
|
22
|
+
A `GET` policy also covers `HEAD`, which Elysia answers with the GET handler.
|
|
22
23
|
5. Add route detail and response schemas so the endpoint is present in the
|
|
23
24
|
merged OpenAPI document at `/api-docs-json`.
|
|
25
|
+
Browsers reach the endpoint through the web `/api` proxy, which drops
|
|
26
|
+
headers it does not know. When the endpoint reads a custom request header or
|
|
27
|
+
returns one the browser needs, add its name to the app-owned
|
|
28
|
+
`apps/web/src/lib/server/proxy-headers.ts`.
|
|
24
29
|
6. Add a `bun:test` unit or integration test beside the feature. Verify with
|
|
25
30
|
`bun run lint`, `bun test`, `bun run build`, and
|
|
26
31
|
`bun run --cwd apps/api contract` without starting a dev server. The contract
|
|
@@ -26,5 +26,11 @@ description: Use when adding or changing a SvelteKit page/route in apps/web —
|
|
|
26
26
|
app-owned `apps/web/src/lib/server/public-paths.ts`. A prefix covers its
|
|
27
27
|
subpaths. Give such a page's loader a fallback for when the API is down
|
|
28
28
|
instead of throwing. Never edit `PUBLIC_PATHS` in the managed `guards.ts`.
|
|
29
|
-
7. **
|
|
29
|
+
7. **File endpoints** (with the admin-dashboard module): `hooks.server.ts` loads
|
|
30
|
+
the session and site settings before every page and endpoint. For a
|
|
31
|
+
`+server.ts` that serves files or feeds and never reads `locals.user`, add
|
|
32
|
+
its prefix to `APP_ASSET_PATHS` in the app-owned
|
|
33
|
+
`apps/web/src/lib/server/asset-paths.ts` to skip both API calls. Pages under
|
|
34
|
+
that prefix still get the full context.
|
|
35
|
+
8. **Verify**: `{{webRun}} build` (includes `svelte-check`)
|
|
30
36
|
and add/adjust a Playwright test in `tests/`.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "{{projectName}} (containerized dev)",
|
|
3
3
|
"dockerComposeFile": ["../compose.dev.yaml"],
|
|
4
|
+
"initializeCommand": "{{packageExecutor}} @podosoft/podokit dev env",
|
|
4
5
|
"service": "api",
|
|
5
6
|
"workspaceFolder": "/app",
|
|
6
7
|
"runServices": ["traefik", "web", "api", "worker", "postgres", "redis", "minio", "minio-init"],
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
-
# Container environment for compose.dev.yaml.
|
|
2
|
-
#
|
|
3
|
-
#
|
|
1
|
+
# Container environment for compose.dev.yaml. Commit this example with
|
|
2
|
+
# development defaults only. podo dev copies it to the untracked .env.docker
|
|
3
|
+
# when that file is missing and fills every empty *_SECRET value with a random
|
|
4
|
+
# one. Put real credentials, such as OAuth or SMTP secrets, in .env.docker.
|
|
5
|
+
# Compose does not interpolate from these files: set container memory limits
|
|
6
|
+
# such as API_MEM_LIMIT=3g in the shell or the project's .env file instead.
|
|
4
7
|
NODE_ENV=development
|
|
5
8
|
PORT=5002
|
|
6
9
|
POSTGRES_HOST=postgres
|
|
@@ -12,7 +15,7 @@ BACKEND_INTERNAL_URL=http://api:5002
|
|
|
12
15
|
CORS_ORIGIN=http://{{projectName}}.localhost,http://localhost
|
|
13
16
|
REDIS_HOST=redis
|
|
14
17
|
REDIS_PORT=6379
|
|
15
|
-
BETTER_AUTH_SECRET=
|
|
18
|
+
BETTER_AUTH_SECRET=
|
|
16
19
|
BETTER_AUTH_URL=http://{{projectName}}.localhost
|
|
17
20
|
ADMIN_EMAILS=admin@example.com
|
|
18
21
|
AUDIT_LOG_ENABLED=true
|
|
@@ -16,11 +16,18 @@ POSTGRES_DB=podokit
|
|
|
16
16
|
# queries, plus DB_AUTH_POOL_MAX for the auth module when it is installed. Keep
|
|
17
17
|
# replicas x (DB_POOL_MAX + DB_AUTH_POOL_MAX), plus a few for migrations, below
|
|
18
18
|
# the server's max_connections (100 by default). The timeouts are milliseconds.
|
|
19
|
+
# DB_QUERY_TIMEOUT_MS bounds how long a query waits for a server that stopped
|
|
20
|
+
# answering; migrations and maintenance commands are not bounded.
|
|
19
21
|
# DB_POOL_MAX=20
|
|
20
22
|
# DB_CONNECT_TIMEOUT_MS=10000
|
|
21
23
|
# DB_IDLE_TIMEOUT_MS=30000
|
|
24
|
+
# DB_QUERY_TIMEOUT_MS=10000
|
|
22
25
|
# DB_AUTH_POOL_MAX=10
|
|
23
26
|
|
|
27
|
+
# Largest API request body in bytes (Bun's default is 128 MiB). Requests through
|
|
28
|
+
# the web app's /api proxy are also bounded by BODY_SIZE_LIMIT below.
|
|
29
|
+
# API_MAX_REQUEST_BODY_SIZE=134217728
|
|
30
|
+
|
|
24
31
|
# Readiness (GET /health/ready): the longest one dependency check may run, and how
|
|
25
32
|
# long a result is reused across probes (0 checks on every request).
|
|
26
33
|
# READINESS_CHECK_TIMEOUT_MS=800
|
|
@@ -44,3 +51,6 @@ REDIS_DB=0
|
|
|
44
51
|
# REDIS_USERNAME=
|
|
45
52
|
# REDIS_PASSWORD=
|
|
46
53
|
REDIS_TLS=false
|
|
54
|
+
# Longest wait in milliseconds for Redis to answer one command. A command that
|
|
55
|
+
# gets no reply in time fails, and its connection is closed and reopened.
|
|
56
|
+
# REDIS_COMMAND_TIMEOUT_MS=1000
|
|
@@ -20,4 +20,6 @@ branch tags, and empty tags, and so should you.
|
|
|
20
20
|
For a hand-managed API WebSocket endpoint, add a `pathType: Exact` Ingress path to
|
|
21
21
|
the API service before the existing `/` web path. Do not route a prefix or the root
|
|
22
22
|
to API. Deployment profiles express the same rule with
|
|
23
|
-
`exposure.webSocketPaths` and validate it before rendering.
|
|
23
|
+
`exposure.webSocketPaths` and validate it before rendering. A large-upload route
|
|
24
|
+
under `/api/` needs `/api` removed on the way to the API; `exposure.apiPaths`
|
|
25
|
+
renders a separate Ingress with a Traefik `stripPrefix` Middleware for that.
|
|
@@ -4,11 +4,16 @@ import type { QueryRunner } from "typeorm";
|
|
|
4
4
|
import { revertLatestSqliteMigration, runSqliteMigrations } from "../database/sqlite-migrator";
|
|
5
5
|
import { IndexSessionCreatedAt1792060000000 } from "../migrations/1792060000000-IndexSessionCreatedAt";
|
|
6
6
|
|
|
7
|
-
function runner(
|
|
7
|
+
function runner(
|
|
8
|
+
type: "postgres" | "better-sqlite3",
|
|
9
|
+
queries: string[],
|
|
10
|
+
{ invalid = false, session = true }: { invalid?: boolean; session?: boolean } = {},
|
|
11
|
+
): QueryRunner {
|
|
8
12
|
return {
|
|
9
13
|
connection: { options: { type } },
|
|
10
14
|
query: (sql: string): Promise<unknown[]> => {
|
|
11
15
|
queries.push(sql);
|
|
16
|
+
if (sql.includes("IS NOT NULL")) return Promise.resolve([{ exists: session }]);
|
|
12
17
|
return Promise.resolve(invalid && sql.includes("indisvalid") ? [{ invalid: 1 }] : []);
|
|
13
18
|
},
|
|
14
19
|
} as unknown as QueryRunner;
|
|
@@ -25,6 +30,7 @@ function rows<Row>(database: Database, sql: string): Row[] {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
const CREATE = 'CREATE INDEX CONCURRENTLY IF NOT EXISTS "IDX_session_createdAt_id" ON "session" ("createdAt", "id")';
|
|
33
|
+
const SESSION_EXISTS = 'SELECT to_regclass($1) IS NOT NULL AS "exists"';
|
|
28
34
|
|
|
29
35
|
describe("admin session list index migration", () => {
|
|
30
36
|
it("builds the PostgreSQL index concurrently outside a transaction", async () => {
|
|
@@ -33,6 +39,7 @@ describe("admin session list index migration", () => {
|
|
|
33
39
|
const up: string[] = [];
|
|
34
40
|
await migration.up(runner("postgres", up));
|
|
35
41
|
expect(up).toEqual([
|
|
42
|
+
SESSION_EXISTS,
|
|
36
43
|
"SELECT 1 AS \"invalid\" FROM pg_index WHERE indexrelid = to_regclass($1) AND NOT indisvalid",
|
|
37
44
|
CREATE,
|
|
38
45
|
]);
|
|
@@ -44,8 +51,30 @@ describe("admin session list index migration", () => {
|
|
|
44
51
|
|
|
45
52
|
it("rebuilds an invalid index left by an interrupted build", async () => {
|
|
46
53
|
const up: string[] = [];
|
|
47
|
-
await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, true));
|
|
48
|
-
expect(up.slice(
|
|
54
|
+
await new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { invalid: true }));
|
|
55
|
+
expect(up.slice(2)).toEqual(['DROP INDEX CONCURRENTLY IF EXISTS "IDX_session_createdAt_id"', CREATE]);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// `migration:run` runs only the application migrations, so on a new database
|
|
59
|
+
// Better Auth has not created its tables yet.
|
|
60
|
+
it("fails with the command that creates the Better Auth tables on PostgreSQL", async () => {
|
|
61
|
+
const up: string[] = [];
|
|
62
|
+
await expect(new IndexSessionCreatedAt1792060000000().up(runner("postgres", up, { session: false })))
|
|
63
|
+
.rejects.toThrow('Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"');
|
|
64
|
+
expect(up).toEqual([SESSION_EXISTS]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("fails with the command that creates the Better Auth tables on SQLite and records nothing", async () => {
|
|
68
|
+
const database = new Database(":memory:");
|
|
69
|
+
try {
|
|
70
|
+
const migrations = [{ timestamp: 1792060000000, migration: new IndexSessionCreatedAt1792060000000() }];
|
|
71
|
+
await expect(runSqliteMigrations(database, migrations)).rejects.toThrow(
|
|
72
|
+
'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all"',
|
|
73
|
+
);
|
|
74
|
+
expect(rows(database, 'SELECT "name" FROM "migrations"')).toEqual([]);
|
|
75
|
+
} finally {
|
|
76
|
+
database.close();
|
|
77
|
+
}
|
|
49
78
|
});
|
|
50
79
|
|
|
51
80
|
it("serves the newest-first session page on SQLite and reverts", async () => {
|
package/dist/templates/modules/admin-dashboard/files/apps/api/src/bootstrap-admin-runtime.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// The command runs against a migrated schema and reports a missing table from its
|
|
4
4
|
// own query. Better Auth's asynchronous startup check would only race the
|
|
5
5
|
// connection the command closes when it finishes.
|
|
6
|
-
import "./auth/
|
|
6
|
+
import "./auth/maintenance-command";
|
|
7
7
|
import { verifyPassword } from "better-auth/crypto";
|
|
8
8
|
import { auth } from "./auth/auth";
|
|
9
9
|
import { closeAuthDatabase, postgresPool, sqliteDatabase } from "./auth/db";
|
|
@@ -5,6 +5,12 @@ import type { MigrationInterface, QueryRunner } from "typeorm";
|
|
|
5
5
|
// so without this index every page sorts the whole session table.
|
|
6
6
|
const INDEX = "IDX_session_createdAt_id";
|
|
7
7
|
|
|
8
|
+
// Better Auth creates "session" in its own schema step. `migrate:all` runs that
|
|
9
|
+
// step before the application migrations; `migration:run` does not.
|
|
10
|
+
const MISSING_SESSION_TABLE =
|
|
11
|
+
'Better Auth has not created the "session" table yet. Run "bun run --cwd apps/api migrate:all", ' +
|
|
12
|
+
"which applies the Better Auth schema before the application migrations.";
|
|
13
|
+
|
|
8
14
|
export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
|
|
9
15
|
name = "IndexSessionCreatedAt1792060000000";
|
|
10
16
|
// CONCURRENTLY cannot run inside a transaction block. The runner therefore
|
|
@@ -13,9 +19,21 @@ export class IndexSessionCreatedAt1792060000000 implements MigrationInterface {
|
|
|
13
19
|
|
|
14
20
|
async up(queryRunner: QueryRunner): Promise<void> {
|
|
15
21
|
if (queryRunner.connection.options.type !== "postgres") {
|
|
16
|
-
|
|
22
|
+
try {
|
|
23
|
+
await queryRunner.query(`CREATE INDEX IF NOT EXISTS "${INDEX}" ON "session" ("createdAt", "id")`);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error instanceof Error && /no such table: (?:main\.)?session$/.test(error.message)) {
|
|
26
|
+
throw new Error(MISSING_SESSION_TABLE, { cause: error });
|
|
27
|
+
}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
17
30
|
return;
|
|
18
31
|
}
|
|
32
|
+
const [session] = await queryRunner.query(
|
|
33
|
+
`SELECT to_regclass($1) IS NOT NULL AS "exists"`,
|
|
34
|
+
[`"session"`],
|
|
35
|
+
) as Array<{ exists: boolean } | undefined>;
|
|
36
|
+
if (!session?.exists) throw new Error(MISSING_SESSION_TABLE);
|
|
19
37
|
// Sign-ins keep writing sessions while the index builds. An interrupted
|
|
20
38
|
// build leaves an invalid index that IF NOT EXISTS would keep.
|
|
21
39
|
const invalid = await queryRunner.query(
|
|
@@ -1,9 +1,12 @@
|
|
|
1
|
-
import { error } from "@sveltejs/kit";
|
|
1
|
+
import { error, type RequestEvent } from "@sveltejs/kit";
|
|
2
2
|
import type { Handle } from "@sveltejs/kit/hooks";
|
|
3
|
+
import { building } from "$app/env";
|
|
4
|
+
import { routes } from "$app/manifest";
|
|
3
5
|
import { serverApiClient } from "#lib/server/api.js";
|
|
4
6
|
import { resolveLocale } from "#lib/i18n/messages.js";
|
|
5
7
|
import { applySearchIndexingHeaders } from "#lib/server/search-indexing.js";
|
|
6
8
|
import { isPublicPath } from "#lib/server/guards.js";
|
|
9
|
+
import { needsRuntimeContext } from "#lib/server/runtime-context.js";
|
|
7
10
|
import type { SiteSettings } from "#lib/site.svelte.js";
|
|
8
11
|
import { ApiError } from "@podosoft/podokit-api-client";
|
|
9
12
|
|
|
@@ -12,15 +15,18 @@ function isTemporaryServiceFailure(cause: unknown): boolean {
|
|
|
12
15
|
return cause.statusCode === 408 || cause.statusCode === 429 || cause.statusCode >= 500;
|
|
13
16
|
}
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
// An anonymous visitor with no site settings, for responses that render no
|
|
19
|
+
// layout. The locale still follows the visitor's cookie.
|
|
20
|
+
function setEmptyRuntimeContext(event: RequestEvent): void {
|
|
21
|
+
event.locals.site = null;
|
|
22
|
+
event.locals.siteUnavailable = false;
|
|
23
|
+
event.locals.user = null;
|
|
24
|
+
event.locals.session = null;
|
|
25
|
+
event.locals.authUnavailable = false;
|
|
26
|
+
event.locals.locale = resolveLocale(event.cookies.get("locale"));
|
|
27
|
+
}
|
|
23
28
|
|
|
29
|
+
async function loadRuntimeContext(event: RequestEvent): Promise<void> {
|
|
24
30
|
// Public site settings (branding, locale/timezone defaults, maintenance, …)
|
|
25
31
|
// and the session are independent, so both requests run at once. The
|
|
26
32
|
// settings are loaded once here and reused by the layout load. The async
|
|
@@ -73,6 +79,18 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|
|
73
79
|
) {
|
|
74
80
|
error(503, "Service temporarily unavailable");
|
|
75
81
|
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export const handle: Handle = async ({ event, resolve }) => {
|
|
85
|
+
// Listed asset endpoints, missing subresources, the API proxy, and
|
|
86
|
+
// prerendering skip the two API lookups. Pages, their data, form actions,
|
|
87
|
+
// remote functions, and the 404 page get the complete runtime context.
|
|
88
|
+
const route = routes.find(({ id }) => id === event.route.id);
|
|
89
|
+
if (needsRuntimeContext(event, { building, route })) {
|
|
90
|
+
await loadRuntimeContext(event);
|
|
91
|
+
} else {
|
|
92
|
+
setEmptyRuntimeContext(event);
|
|
93
|
+
}
|
|
76
94
|
const response = await resolve(event, {
|
|
77
95
|
transformPageChunk: ({ html }) =>
|
|
78
96
|
html.replace(
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Application-owned prefixes for `+server.ts` endpoints that serve files, feeds,
|
|
3
|
+
* or other responses needing neither the session nor the site settings (for
|
|
4
|
+
* example "/docs" when an endpoint serves documentation files). A listed path
|
|
5
|
+
* also covers its subpaths. When an endpoint under a listed prefix answers a
|
|
6
|
+
* request, hooks.server.ts skips its two API lookups and the endpoint sees an
|
|
7
|
+
* anonymous `locals.user`. A `+page` under a listed prefix still gets the
|
|
8
|
+
* session and site settings, so it keeps its sign-in and maintenance checks.
|
|
9
|
+
* PodoKit creates this file once and never overwrites it.
|
|
10
|
+
*/
|
|
11
|
+
export const APP_ASSET_PATHS: readonly string[] = [];
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.test.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Stands in for the application's own list in the app-owned asset-paths.ts.
|
|
4
|
+
vi.mock("./asset-paths", () => ({ APP_ASSET_PATHS: ["/docs", "/feed.xml"] }));
|
|
5
|
+
|
|
6
|
+
const { needsRuntimeContext, prefersHtml } = await import("./runtime-context");
|
|
7
|
+
|
|
8
|
+
const NAVIGATION = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8";
|
|
9
|
+
const IMAGE = "image/avif,image/webp,image/apng,*/*;q=0.8";
|
|
10
|
+
|
|
11
|
+
interface RequestOptions {
|
|
12
|
+
routeId?: string | null;
|
|
13
|
+
method?: string;
|
|
14
|
+
headers?: Record<string, string>;
|
|
15
|
+
isDataRequest?: boolean;
|
|
16
|
+
isRemoteRequest?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function request(path: string, options: RequestOptions = {}) {
|
|
20
|
+
const url = new URL(path, "http://app.localhost");
|
|
21
|
+
return {
|
|
22
|
+
url,
|
|
23
|
+
route: { id: options.routeId === undefined ? url.pathname : options.routeId },
|
|
24
|
+
request: new Request(url, { method: options.method ?? "GET", headers: options.headers }),
|
|
25
|
+
isDataRequest: options.isDataRequest ?? false,
|
|
26
|
+
isRemoteRequest: options.isRemoteRequest ?? false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("needsRuntimeContext", () => {
|
|
31
|
+
it("loads the session and site settings for pages and their data", () => {
|
|
32
|
+
expect(needsRuntimeContext(request("/account", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
33
|
+
expect(needsRuntimeContext(request("/account", { headers: { accept: "*/*" } }))).toBe(true);
|
|
34
|
+
expect(needsRuntimeContext(request("/account", { isDataRequest: true }))).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("loads them for an unmatched route because the 404 page renders the root layout", () => {
|
|
38
|
+
// Without the session a signed-in visitor was sent to /login, which sent
|
|
39
|
+
// them straight back: a redirect loop on every missing page.
|
|
40
|
+
expect(
|
|
41
|
+
needsRuntimeContext(
|
|
42
|
+
request("/missing", { routeId: null, headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
|
|
43
|
+
),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
expect(needsRuntimeContext(request("/missing", { routeId: null, headers: { accept: "*/*" } }))).toBe(true);
|
|
46
|
+
expect(needsRuntimeContext(request("/missing", { routeId: null, isDataRequest: true }))).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("skips them for a missing subresource that SvelteKit answers without a layout", () => {
|
|
50
|
+
for (const destination of ["image", "script", "style", "font"]) {
|
|
51
|
+
expect(
|
|
52
|
+
needsRuntimeContext(request("/missing.png", { routeId: null, headers: { "sec-fetch-dest": destination } })),
|
|
53
|
+
).toBe(false);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("skips them for the backend proxy", () => {
|
|
58
|
+
expect(needsRuntimeContext(request("/api", { routeId: "/api/[...path]" }))).toBe(false);
|
|
59
|
+
expect(needsRuntimeContext(request("/api/site/settings", { routeId: "/api/[...path]" }))).toBe(false);
|
|
60
|
+
expect(needsRuntimeContext(request("/api/auth/get-session", { routeId: "/api/auth/[...all]" }))).toBe(false);
|
|
61
|
+
expect(needsRuntimeContext(request("/apiary", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("skips them while prerendering", () => {
|
|
65
|
+
expect(needsRuntimeContext(request("/pricing", { headers: { accept: NAVIGATION } }), { building: true })).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("loads them for remote functions, which may read the session without a route", () => {
|
|
69
|
+
expect(needsRuntimeContext(request("/_app/remote/abc/list", { routeId: null, isRemoteRequest: true }))).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("skips them when an endpoint under an application asset path answers", () => {
|
|
73
|
+
const files = { page: false, endpoint: true };
|
|
74
|
+
expect(
|
|
75
|
+
needsRuntimeContext(
|
|
76
|
+
request("/docs/guide/diagram.png", { routeId: "/docs/[...path]", headers: { accept: IMAGE, "sec-fetch-dest": "image" } }),
|
|
77
|
+
{ route: files },
|
|
78
|
+
),
|
|
79
|
+
).toBe(false);
|
|
80
|
+
// Opening a file in a tab is a navigation, but no page renders.
|
|
81
|
+
expect(
|
|
82
|
+
needsRuntimeContext(
|
|
83
|
+
request("/docs/manual.pdf", { routeId: "/docs/[...path]", headers: { accept: NAVIGATION, "sec-fetch-dest": "document" } }),
|
|
84
|
+
{ route: files },
|
|
85
|
+
),
|
|
86
|
+
).toBe(false);
|
|
87
|
+
expect(needsRuntimeContext(request("/feed.xml", { headers: { accept: "application/rss+xml" } }), { route: files })).toBe(false);
|
|
88
|
+
expect(needsRuntimeContext(request("/documents", { headers: { accept: "*/*" } }), { route: files })).toBe(true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps them when a page under an application asset path answers", () => {
|
|
92
|
+
const page = { page: true, endpoint: false };
|
|
93
|
+
expect(needsRuntimeContext(request("/docs", { headers: { accept: "*/*" } }), { route: page })).toBe(true);
|
|
94
|
+
expect(needsRuntimeContext(request("/docs", { headers: { accept: NAVIGATION } }), { route: page })).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("follows SvelteKit's page or endpoint choice when an asset route has both", () => {
|
|
98
|
+
const both = { page: true, endpoint: true };
|
|
99
|
+
const docs = (options: RequestOptions) =>
|
|
100
|
+
needsRuntimeContext(request("/docs/intro", { routeId: "/docs/[slug]", ...options }), { route: both });
|
|
101
|
+
expect(docs({ headers: { accept: NAVIGATION } })).toBe(true);
|
|
102
|
+
expect(docs({ isDataRequest: true })).toBe(true);
|
|
103
|
+
expect(docs({ method: "POST", headers: { accept: "application/json", "x-sveltekit-action": "true" } })).toBe(true);
|
|
104
|
+
expect(docs({ headers: { accept: IMAGE } })).toBe(false);
|
|
105
|
+
expect(docs({ headers: { accept: "*/*" } })).toBe(false);
|
|
106
|
+
expect(docs({ method: "PUT", headers: { accept: NAVIGATION } })).toBe(false);
|
|
107
|
+
// Without route information the same negotiation applies.
|
|
108
|
+
expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: IMAGE } }))).toBe(false);
|
|
109
|
+
expect(needsRuntimeContext(request("/docs/intro", { headers: { accept: NAVIGATION } }))).toBe(true);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("prefersHtml", () => {
|
|
114
|
+
it("follows SvelteKit's page and endpoint negotiation", () => {
|
|
115
|
+
expect(prefersHtml(NAVIGATION)).toBe(true);
|
|
116
|
+
expect(prefersHtml("text/*")).toBe(true);
|
|
117
|
+
expect(prefersHtml("*/*")).toBe(false);
|
|
118
|
+
expect(prefersHtml(null)).toBe(false);
|
|
119
|
+
expect(prefersHtml(IMAGE)).toBe(false);
|
|
120
|
+
expect(prefersHtml("application/json, text/html;q=0.5")).toBe(true);
|
|
121
|
+
expect(prefersHtml("application/json, */*;q=0.9, text/html;q=0.5")).toBe(false);
|
|
122
|
+
expect(prefersHtml("*/*;q=0.9, text/html")).toBe(true);
|
|
123
|
+
expect(prefersHtml("text/html;q=0, */*")).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/runtime-context.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { APP_ASSET_PATHS } from "./asset-paths";
|
|
2
|
+
|
|
3
|
+
/** The request fields that decide whether a response can use the session and site settings. */
|
|
4
|
+
export interface RuntimeContextRequest {
|
|
5
|
+
url: URL;
|
|
6
|
+
route: { id: string | null };
|
|
7
|
+
request: Request;
|
|
8
|
+
isDataRequest: boolean;
|
|
9
|
+
isRemoteRequest: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Fetch destinations that SvelteKit answers with a plain-text 404, without the
|
|
13
|
+
// root layout, when no route matches.
|
|
14
|
+
const SUBRESOURCE_DESTINATIONS = new Set([
|
|
15
|
+
"audio",
|
|
16
|
+
"audioworklet",
|
|
17
|
+
"font",
|
|
18
|
+
"image",
|
|
19
|
+
"json",
|
|
20
|
+
"manifest",
|
|
21
|
+
"paintworklet",
|
|
22
|
+
"report",
|
|
23
|
+
"script",
|
|
24
|
+
"serviceworker",
|
|
25
|
+
"sharedworker",
|
|
26
|
+
"style",
|
|
27
|
+
"track",
|
|
28
|
+
"video",
|
|
29
|
+
"webidentity",
|
|
30
|
+
"worker",
|
|
31
|
+
"xslt",
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
function underPrefix(pathname: string, prefix: string): boolean {
|
|
35
|
+
return pathname === prefix || (prefix !== "/" && pathname.startsWith(`${prefix}/`));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* True when an Accept header ranks HTML ahead of the wildcard. This mirrors how
|
|
40
|
+
* SvelteKit chooses between a page and an endpoint on the same route.
|
|
41
|
+
*/
|
|
42
|
+
export function prefersHtml(accept: string | null): boolean {
|
|
43
|
+
const ranges = (accept ?? "*/*")
|
|
44
|
+
.split(",")
|
|
45
|
+
.map((part, index) => {
|
|
46
|
+
const [range = "", ...params] = part.trim().toLowerCase().split(";");
|
|
47
|
+
const quality = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
|
|
48
|
+
return { range: range.trim(), q: quality ? Number(quality.slice(2)) : 1, index };
|
|
49
|
+
})
|
|
50
|
+
.filter(({ range, q }) => range.includes("/") && q > 0)
|
|
51
|
+
.sort((a, b) => b.q - a.q || specificity(b.range) - specificity(a.range) || a.index - b.index);
|
|
52
|
+
const html = ranges.findIndex(({ range }) => range === "text/html" || range === "text/*");
|
|
53
|
+
const wildcard = ranges.findIndex(({ range }) => range === "*/*");
|
|
54
|
+
return html !== -1 && (wildcard === -1 || html < wildcard);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function specificity(range: string): number {
|
|
58
|
+
if (range === "*/*") return 0;
|
|
59
|
+
return range.endsWith("/*") ? 1 : 2;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Which files the matched route has, from `routes` in `$app/manifest`. */
|
|
63
|
+
export interface RouteFiles {
|
|
64
|
+
page: boolean;
|
|
65
|
+
endpoint: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** True when SvelteKit answers the request with the route's `+page` rather than its `+server`. */
|
|
69
|
+
function answeredByPage(event: RuntimeContextRequest, route: RouteFiles | undefined): boolean {
|
|
70
|
+
if (route && !route.page) return false;
|
|
71
|
+
if (route && !route.endpoint) return true;
|
|
72
|
+
if (event.isDataRequest) return true;
|
|
73
|
+
const { headers, method } = event.request;
|
|
74
|
+
if (method !== "GET" && method !== "HEAD" && method !== "POST") return false;
|
|
75
|
+
if (method === "POST" && headers.get("x-sveltekit-action") === "true") return true;
|
|
76
|
+
return prefersHtml(headers.get("accept"));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Decide whether hooks.server.ts must load the session and the site settings
|
|
81
|
+
* before SvelteKit answers the request. It skips them only when the response
|
|
82
|
+
* cannot render a layout, or when the application listed the path in
|
|
83
|
+
* APP_ASSET_PATHS and a `+server` endpoint, not a `+page`, answers it.
|
|
84
|
+
*/
|
|
85
|
+
export function needsRuntimeContext(
|
|
86
|
+
event: RuntimeContextRequest,
|
|
87
|
+
options: { building?: boolean; route?: RouteFiles } = {},
|
|
88
|
+
): boolean {
|
|
89
|
+
// Prerendering runs at build time without the API, and the stored HTML must
|
|
90
|
+
// not depend on one visitor's session or on runtime site settings.
|
|
91
|
+
if (options.building) return false;
|
|
92
|
+
const { pathname } = event.url;
|
|
93
|
+
// The backend proxy forwards the cookies; the API resolves the session itself.
|
|
94
|
+
if (underPrefix(pathname, "/api")) return false;
|
|
95
|
+
// Remote functions run application code that may read the session.
|
|
96
|
+
if (event.isRemoteRequest) return true;
|
|
97
|
+
if (event.route.id === null) {
|
|
98
|
+
// SvelteKit renders its 404 page with the root layout, which redirects or
|
|
99
|
+
// greets the visitor, unless the browser fetched a subresource.
|
|
100
|
+
const destination = event.request.headers.get("sec-fetch-dest") ?? "";
|
|
101
|
+
return !SUBRESOURCE_DESTINATIONS.has(destination);
|
|
102
|
+
}
|
|
103
|
+
if (APP_ASSET_PATHS.some((prefix) => underPrefix(pathname, prefix))) {
|
|
104
|
+
return answeredByPage(event, options.route);
|
|
105
|
+
}
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
import { expectObjectLength } from "../helpers/object-response";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -48,7 +49,7 @@ test("profile image upload, replacement, public read, and removal", async ({ pla
|
|
|
48
49
|
expect(firstImage.ok()).toBeTruthy();
|
|
49
50
|
expect(firstImage.headers()["content-type"]).toContain("image/png");
|
|
50
51
|
expect(firstImage.headers()["cache-control"]).toBe("public, max-age=31536000, immutable");
|
|
51
|
-
|
|
52
|
+
expectObjectLength(firstImage.headers(), PNG.byteLength);
|
|
52
53
|
expect((await firstImage.body()).equals(PNG)).toBe(true);
|
|
53
54
|
const etag = firstImage.headers()["etag"];
|
|
54
55
|
expect(etag).toMatch(/^"[^"]+"$/);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { expect, test } from "@playwright/test";
|
|
2
2
|
import { ADMIN } from "../helpers/accounts";
|
|
3
|
+
import { expectObjectLength } from "../helpers/object-response";
|
|
3
4
|
|
|
4
5
|
const base = process.env.E2E_BASE_URL ?? "http://localhost:5001";
|
|
5
6
|
const origin = { origin: base };
|
|
@@ -101,7 +102,7 @@ test("site favicon: the versioned URL is cached for good and other URLs revalida
|
|
|
101
102
|
expect(current.status()).toBe(200);
|
|
102
103
|
expect(current.headers()["cache-control"]).toBe(IMMUTABLE);
|
|
103
104
|
expect(current.headers()["content-type"]).toContain("image/png");
|
|
104
|
-
|
|
105
|
+
expectObjectLength(current.headers(), ICON.byteLength);
|
|
105
106
|
expect((await current.body()).equals(ICON)).toBe(true);
|
|
106
107
|
const etag = current.headers()["etag"] ?? "";
|
|
107
108
|
expect(etag).toMatch(/^"[^"]+"$/);
|
|
@@ -35,16 +35,16 @@ async function readCreatedUserId(response: CreatedUserResponse): Promise<string>
|
|
|
35
35
|
export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
36
36
|
disposableUsers: async ({ context, playwright }, use, testInfo) => {
|
|
37
37
|
// API projects do not inherit the UI project's admin storageState, while a
|
|
38
|
-
// second admin sign-in can invalidate the shared UI session.
|
|
39
|
-
// isolated request context
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
// second admin sign-in can invalidate the shared UI session. API tests sign in
|
|
39
|
+
// an isolated request context. UI tests copy the browser's admin cookies into
|
|
40
|
+
// their own context once, so a test that later signs its page in as another
|
|
41
|
+
// user does not turn cleanup into that user's (forbidden) requests.
|
|
42
|
+
const request = await playwright.request.newContext({
|
|
43
|
+
baseURL,
|
|
44
|
+
extraHTTPHeaders: origin,
|
|
45
|
+
...(testInfo.project.name === "api" ? {} : { storageState: await context.storageState() }),
|
|
46
|
+
});
|
|
42
47
|
if (testInfo.project.name === "api") {
|
|
43
|
-
request = await playwright.request.newContext({
|
|
44
|
-
baseURL,
|
|
45
|
-
extraHTTPHeaders: origin,
|
|
46
|
-
});
|
|
47
|
-
ownsRequest = true;
|
|
48
48
|
const signIn = await request.post("/api/auth/sign-in/email", {
|
|
49
49
|
data: { email: ADMIN.email, password: ADMIN.password },
|
|
50
50
|
});
|
|
@@ -94,7 +94,7 @@ export const test = base.extend<{ disposableUsers: DisposableUsers }>({
|
|
|
94
94
|
}
|
|
95
95
|
}
|
|
96
96
|
} finally {
|
|
97
|
-
|
|
97
|
+
await request.dispose();
|
|
98
98
|
}
|
|
99
99
|
}
|
|
100
100
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { expect } from "@playwright/test";
|
|
2
|
+
|
|
3
|
+
// IN_MEMORY_READ_BYTES of @podosoft/podokit-runtime. Both object storage
|
|
4
|
+
// providers and the web proxy keep the Content-Length of objects up to this
|
|
5
|
+
// size; Bun may send larger ones chunked.
|
|
6
|
+
const IN_MEMORY_READ_BYTES = 2 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export function expectObjectLength(headers: Record<string, string>, size: number): void {
|
|
9
|
+
const length = headers["content-length"];
|
|
10
|
+
if (length === undefined && size > IN_MEMORY_READ_BYTES) return;
|
|
11
|
+
expect(length).toBe(String(size));
|
|
12
|
+
}
|