@podosoft/podokit 1.5.0 → 1.6.0
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 +2 -1
- package/dist/app-proxy-timeouts.d.ts +27 -0
- package/dist/app-proxy-timeouts.js +180 -0
- package/dist/assemble.d.ts +5 -2
- package/dist/assemble.js +14 -4
- package/dist/deploy-checks.d.ts +44 -0
- package/dist/deploy-checks.js +267 -0
- package/dist/deploy-compose-profile.d.ts +6 -2
- package/dist/deploy-compose-profile.js +15 -6
- package/dist/deploy-compose-render.d.ts +12 -4
- package/dist/deploy-compose-render.js +47 -18
- package/dist/deploy-compose.d.ts +2 -5
- package/dist/deploy-compose.js +7 -2
- package/dist/deploy-profile.d.ts +6 -2
- package/dist/deploy-profile.js +11 -7
- package/dist/deploy-render.d.ts +4 -1
- package/dist/deploy-render.js +27 -21
- package/dist/deploy-schema.d.ts +56 -2
- package/dist/deploy-schema.js +130 -14
- package/dist/deploy.d.ts +2 -5
- package/dist/deploy.js +5 -2
- package/dist/dev.d.ts +21 -0
- package/dist/dev.js +78 -3
- package/dist/eject.js +2 -0
- package/dist/index.js +85 -5
- package/dist/lib.d.ts +2 -0
- package/dist/lib.js +5 -2
- package/dist/lockfile.d.ts +24 -0
- package/dist/lockfile.js +74 -4
- package/dist/provider.js +10 -5
- package/dist/remove.js +1 -1
- package/dist/template-files.d.ts +42 -0
- package/dist/template-files.js +102 -0
- package/dist/templates/fullstack/AGENTS.md +28 -4
- package/dist/templates/fullstack/README.md +8 -7
- package/dist/templates/fullstack/apps/api/package.json +4 -4
- package/dist/templates/fullstack/apps/api/scripts/build.extensions.mjs +6 -0
- package/dist/templates/fullstack/apps/api/scripts/build.mjs +65 -16
- package/dist/templates/fullstack/apps/api/scripts/run-migrations.mjs +7 -3
- package/dist/templates/fullstack/apps/api/scripts/verify-api-contract.ts +5 -1
- package/dist/templates/fullstack/apps/api/src/app.spec.ts +22 -0
- package/dist/templates/fullstack/apps/api/src/app.ts +41 -25
- package/dist/templates/fullstack/apps/api/src/build.spec.ts +118 -0
- package/dist/templates/fullstack/apps/api/src/config/env.extensions.ts +20 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.spec.ts +76 -0
- package/dist/templates/fullstack/apps/api/src/config/env.validation.ts +18 -3
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.spec.ts +53 -0
- package/dist/templates/fullstack/apps/api/src/config/provider-selection.ts +79 -0
- package/dist/templates/fullstack/apps/api/src/config/providers.ts +7 -3
- package/dist/templates/fullstack/apps/api/src/core/api-contract.extensions.ts +6 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.spec.ts +29 -0
- package/dist/templates/fullstack/apps/api/src/core/api-contract.ts +39 -2
- package/dist/templates/fullstack/apps/api/src/core/services.ts +13 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.spec.ts +27 -0
- package/dist/templates/fullstack/apps/api/src/database/data-source.ts +7 -10
- package/dist/templates/fullstack/apps/api/src/database/database.spec.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/database.ts +2 -3
- package/dist/templates/fullstack/apps/api/src/database/entities.ts +12 -0
- package/dist/templates/fullstack/apps/api/src/database/migrate.ts +12 -2
- package/dist/templates/fullstack/apps/api/src/database/migration-files.spec.ts +116 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-files.ts +134 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.spec.ts +36 -0
- package/dist/templates/fullstack/apps/api/src/database/migration-readiness.ts +25 -0
- package/dist/templates/fullstack/apps/api/src/database/run-migrations.spec.ts +12 -1
- package/dist/templates/fullstack/apps/api/src/database/sqlite-migrator.ts +2 -36
- package/dist/templates/fullstack/apps/api/src/main.ts +2 -2
- package/dist/templates/fullstack/apps/api/tsconfig.json +2 -0
- package/dist/templates/fullstack/apps/web/package.json +1 -1
- package/dist/templates/fullstack/apps/web/src/app.css +3 -0
- package/dist/templates/fullstack/apps/web/src/app.extensions.css +11 -0
- package/dist/templates/fullstack/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/api.test.ts +19 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/api.ts +4 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.test.ts +79 -0
- package/dist/templates/fullstack/apps/web/src/lib/server/backend-proxy.ts +55 -1
- package/dist/templates/fullstack/apps/web/src/lib/server/proxy-timeouts.ts +2 -1
- package/dist/templates/fullstack/apps/web/tsconfig.json +1 -1
- package/dist/templates/fullstack/apps/web/vite.config.test.ts +33 -0
- package/dist/templates/fullstack/apps/web/vite.config.ts +55 -13
- package/dist/templates/fullstack/apps/web/vite.extensions.ts +23 -0
- package/dist/templates/fullstack/compose.dev.yaml +7 -4
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/SKILL.md +2 -2
- package/dist/templates/fullstack/dot-agents/skills/podokit-deploy/references/profile.md +12 -3
- package/dist/templates/fullstack/dot-claude/skills/podokit-update/SKILL.md +5 -2
- package/dist/templates/fullstack/dot-env.docker.example +5 -2
- package/dist/templates/fullstack/dot-env.example +4 -0
- package/dist/templates/fullstack/dot-podokit/dev.json +1 -1
- package/dist/templates/fullstack/infra/k3s/configmap.yaml +1 -0
- package/dist/templates/fullstack/tests/README.md +40 -1
- package/dist/templates/fullstack/tests/helpers/app-paths.ts +18 -0
- package/dist/templates/fullstack/tests/helpers/paths.ts +33 -0
- package/dist/templates/fullstack/tests/package.json +2 -2
- package/dist/templates/fullstack/tests/playwright.config.ts +7 -3
- package/dist/templates/fullstack/tests/playwright.extensions.ts +88 -1
- package/dist/templates/fullstack/tests/ui/smoke.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.test.ts +37 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/hooks.server.ts +15 -6
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flag-settings.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-flags.ts +12 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/app-nav.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.test.ts +39 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/nav.ts +35 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/admin/registry.svelte.ts +15 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/account-page.svelte +11 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/admin-sidebar.svelte +2 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.svelte +7 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/data-table.test.ts +30 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/impersonation-banner.svelte +3 -2
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/settings/app-flags-settings.svelte +65 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.svelte +32 -23
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/sidebar-user-menu.test.ts +27 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-footer.svelte +4 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/components/site-runtime.svelte +3 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/en.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/i18n/catalogs/admin-dashboard/ko.json +6 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/app-hooks.ts +11 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/non-indexable-paths.ts +8 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.test.ts +31 -0
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/lib/server/search-indexing.ts +5 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/(admin)/admin/settings/+page.svelte +1 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.server.ts +2 -1
- package/dist/templates/modules/admin-dashboard/files/apps/web/src/routes/account/+page.svelte +1 -5
- package/dist/templates/modules/admin-dashboard/files/dot-claude/skills/podokit-data-table/SKILL.md +2 -0
- package/dist/templates/modules/admin-dashboard/files/tests/api/audit.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth-config.api.spec.ts +159 -170
- package/dist/templates/modules/admin-dashboard/files/tests/api/auth.api.spec.ts +72 -81
- package/dist/templates/modules/admin-dashboard/files/tests/api/email.api.spec.ts +1 -6
- package/dist/templates/modules/admin-dashboard/files/tests/api/profile-image.api.spec.ts +1 -1
- package/dist/templates/modules/admin-dashboard/files/tests/api/site-settings.api.spec.ts +43 -53
- package/dist/templates/modules/admin-dashboard/files/tests/api/users-admin.api.spec.ts +3 -1
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/accounts.ts +24 -6
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/app-accounts.ts +24 -0
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/devices.ts +41 -11
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/disposable-users.ts +5 -3
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/mailpit.ts +3 -9
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/sms.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/playwright.config.ts +11 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account-flows.ui.spec.ts +4 -5
- package/dist/templates/modules/admin-dashboard/files/tests/ui/account.ui.spec.ts +55 -55
- package/dist/templates/modules/admin-dashboard/files/tests/ui/app-flags.ui.spec.ts +63 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/appearance.ui.spec.ts +12 -16
- package/dist/templates/modules/admin-dashboard/files/tests/ui/audit.ui.spec.ts +3 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/auth.ui.spec.ts +32 -35
- package/dist/templates/modules/admin-dashboard/files/tests/ui/dashboard.ui.spec.ts +4 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email-admin.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/email.ui.spec.ts +1 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/general-effects.ui.spec.ts +82 -91
- package/dist/templates/modules/admin-dashboard/files/tests/ui/guards.ui.spec.ts +6 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/i18n.ui.spec.ts +16 -20
- package/dist/templates/modules/admin-dashboard/files/tests/ui/impersonation.ui.spec.ts +6 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/login-methods.ui.spec.ts +2 -3
- package/dist/templates/modules/admin-dashboard/files/tests/ui/oidc.ui.spec.ts +2 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/organizations.ui.spec.ts +5 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/passkey.ui.spec.ts +7 -2
- package/dist/templates/modules/admin-dashboard/files/tests/ui/same-origin-requests.ui.spec.ts +89 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/sessions.ui.spec.ts +5 -4
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings-dialogs.ui.spec.ts +67 -73
- package/dist/templates/modules/admin-dashboard/files/tests/ui/settings.ui.spec.ts +84 -93
- package/dist/templates/modules/admin-dashboard/files/tests/ui/table-toolbar.ui.spec.ts +8 -7
- package/dist/templates/modules/admin-dashboard/files/tests/ui/theme-mode.ui.spec.ts +29 -0
- package/dist/templates/modules/admin-dashboard/files/tests/ui/two-factor.ui.spec.ts +51 -54
- package/dist/templates/modules/admin-dashboard/files/tests/ui/users.ui.spec.ts +10 -9
- package/dist/templates/modules/admin-dashboard/module.manifest.json +7 -2
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.module.ts +5 -1
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.spec.ts +17 -0
- package/dist/templates/modules/audit-log/files/apps/api/src/audit/audit.service.ts +18 -0
- package/dist/templates/modules/audit-log/module.manifest.json +15 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-app-name.spec.ts +21 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-config-store.ts +13 -1
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-environment.ts +9 -2
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth-provider.ts +19 -3
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.module.ts +2 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.runtime.spec.ts +10 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/auth.ts +7 -5
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.spec.ts +86 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth/schema-recheck.ts +44 -0
- package/dist/templates/modules/auth/files/apps/api/src/auth-config/auth-config.service.ts +2 -1
- package/dist/templates/modules/auth/files/apps/api/src/migrate.ts +3 -1
- package/dist/templates/modules/auth/files/apps/api/src/settings/app-flags.ts +9 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.spec.ts +43 -0
- package/dist/templates/modules/auth/files/apps/api/src/settings/flag-defaults.ts +30 -5
- package/dist/templates/modules/auth/module.manifest.json +8 -2
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.spec.ts +57 -1
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/bullmq-job.queue.ts +93 -9
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.spec.ts +40 -0
- package/dist/templates/modules/bullmq/files/apps/api/src/jobs/worker.module.ts +52 -5
- package/dist/templates/modules/bullmq/files/apps/api/src/main-worker.ts +9 -7
- package/dist/templates/modules/bullmq/files/apps/api/src/worker.extensions.ts +13 -0
- package/dist/templates/modules/bullmq/module.manifest.json +5 -1
- package/dist/templates/modules/job-progress/files/apps/api/src/progress/progress.worker.spec.ts +2 -0
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.spec.ts +87 -1
- package/dist/templates/modules/jobs-local/files/apps/api/src/jobs-local/local-job.queue.ts +93 -3
- package/dist/templates/modules/jobs-local/module.manifest.json +3 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.spec.ts +16 -0
- package/dist/templates/modules/logging/files/apps/api/src/logging/logging.module.ts +15 -10
- package/dist/templates/modules/mailer/module.manifest.json +1 -1
- package/dist/templates/modules/object-storage-local/files/tests/api/storage-local.api.spec.ts +1 -1
- package/dist/templates/modules/object-storage-s3/files/infra/docker/minio.compose.yml +5 -4
- package/dist/templates/modules/object-storage-s3/files/tests/api/storage.api.spec.ts +1 -1
- package/dist/templates/modules/rate-limit/files/tests/api/rate-limit.api.spec.ts +45 -20
- package/dist/templates/modules/sse/files/tests/api/events.api.spec.ts +1 -1
- package/dist/templates/todo/AGENTS.md +5 -1
- package/dist/templates/todo/README.md +6 -4
- package/dist/text-diff.d.ts +12 -0
- package/dist/text-diff.js +111 -0
- package/dist/update.d.ts +61 -3
- package/dist/update.js +152 -27
- package/package.json +2 -2
- package/dist/templates/modules/admin-dashboard/files/tests/api/exclusive.api.spec.ts +0 -75
- package/dist/templates/modules/admin-dashboard/files/tests/helpers/exclusive.ts +0 -80
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { SQL } from "bun";
|
|
3
|
+
import { pendingMigrationsCheck } from "./migration-readiness";
|
|
4
|
+
|
|
5
|
+
const set = {
|
|
6
|
+
directory: "dist/migrations",
|
|
7
|
+
compiled: true,
|
|
8
|
+
files: [
|
|
9
|
+
{ file: "1700000000001-First.js", name: "First1700000000001" },
|
|
10
|
+
{ file: "1700000000002-Second.js", name: "Second1700000000002" },
|
|
11
|
+
],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("pending migrations readiness", () => {
|
|
15
|
+
it("is down until every listed migration is applied, then stops querying", async () => {
|
|
16
|
+
const sql = new SQL(":memory:");
|
|
17
|
+
try {
|
|
18
|
+
const check = pendingMigrationsCheck(() => sql, set);
|
|
19
|
+
await expect(check()).rejects.toThrow();
|
|
20
|
+
await sql`CREATE TABLE "migrations" ("id" integer PRIMARY KEY, "timestamp" bigint, "name" text)`;
|
|
21
|
+
await sql`INSERT INTO "migrations" ("timestamp", "name") VALUES (1700000000001, 'First1700000000001')`;
|
|
22
|
+
await expect(check()).rejects.toThrow("1 migrations are pending; run migrate:all: Second1700000000002");
|
|
23
|
+
await sql`INSERT INTO "migrations" ("timestamp", "name") VALUES (1700000000002, 'Second1700000000002')`;
|
|
24
|
+
await expect(check()).resolves.toBeUndefined();
|
|
25
|
+
await sql`DROP TABLE "migrations"`;
|
|
26
|
+
await expect(check()).resolves.toBeUndefined();
|
|
27
|
+
} finally {
|
|
28
|
+
await sql.close();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("is up without a query when the build has no migrations", async () => {
|
|
33
|
+
const check = pendingMigrationsCheck(() => { throw new Error("no database"); }, { ...set, files: [] });
|
|
34
|
+
await expect(check()).resolves.toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { ReadinessCheck } from "../health/readiness.service";
|
|
3
|
+
import type { MigrationSet } from "./migration-files";
|
|
4
|
+
|
|
5
|
+
/** Reported down while the database lacks a migration of this build. */
|
|
6
|
+
export const MIGRATIONS_READINESS_CHECK = "migrations";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compares the migrations table with the build's list. Once every migration is
|
|
10
|
+
* applied the check stays up without a query: this process never runs one.
|
|
11
|
+
*/
|
|
12
|
+
export function pendingMigrationsCheck(sql: () => SQL, set: MigrationSet): ReadinessCheck {
|
|
13
|
+
const expected = set.files.flatMap((entry) => entry.name ? [entry.name] : []);
|
|
14
|
+
let applied = expected.length === 0;
|
|
15
|
+
return async (): Promise<void> => {
|
|
16
|
+
if (applied) return;
|
|
17
|
+
const rows = await sql()<Array<{ name: string }>>`SELECT "name" FROM "migrations"`;
|
|
18
|
+
const done = new Set(rows.map((row) => row.name));
|
|
19
|
+
const pending = expected.filter((name) => !done.has(name));
|
|
20
|
+
if (pending.length > 0) {
|
|
21
|
+
throw new Error(`${pending.length} migrations are pending; run migrate:all: ${pending.join(", ")}`);
|
|
22
|
+
}
|
|
23
|
+
applied = true;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -15,6 +15,8 @@ interface LaunchOptions {
|
|
|
15
15
|
/** Starts the launcher from the directory above the API, like a repository root. */
|
|
16
16
|
fromParent?: boolean;
|
|
17
17
|
files?: Record<string, string>;
|
|
18
|
+
/** Arguments after the launcher, such as --list. */
|
|
19
|
+
args?: string[];
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
/** Runs the migrate:all launcher in an API layout whose entries print their own path. */
|
|
@@ -32,7 +34,8 @@ async function launch(entries: string[], nodeEnv: string, body = "", options: La
|
|
|
32
34
|
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
33
35
|
writeFileSync(join(root, path), content);
|
|
34
36
|
}
|
|
35
|
-
const
|
|
37
|
+
const launcher = options.fromParent ? "api/scripts/run-migrations.mjs" : "scripts/run-migrations.mjs";
|
|
38
|
+
const child = Bun.spawn([process.execPath, launcher, ...(options.args ?? [])], {
|
|
36
39
|
cwd: options.fromParent ? parent : root,
|
|
37
40
|
env: { ...process.env, NODE_ENV: nodeEnv },
|
|
38
41
|
stdout: "pipe",
|
|
@@ -68,6 +71,14 @@ describe("migrate:all launcher", () => {
|
|
|
68
71
|
.toMatchObject({ code: 0, stdout: "dist/migrate.js" });
|
|
69
72
|
});
|
|
70
73
|
|
|
74
|
+
it("lists migrations through the application migrator, which needs no auth", async () => {
|
|
75
|
+
const body = "console.log(process.argv.slice(2).join(' '));\n";
|
|
76
|
+
expect(await launch(["src/migrate.ts", "src/database/migrate.ts"], "development", body, { args: ["--list"] }))
|
|
77
|
+
.toMatchObject({ code: 0, stdout: "src/database/migrate.ts\n--list" });
|
|
78
|
+
expect(await launch(["dist/migrate.js", "dist/database/migrate.js"], "production", body, { args: ["--list"] }))
|
|
79
|
+
.toMatchObject({ code: 0, stdout: "dist/database/migrate.js\n--list" });
|
|
80
|
+
});
|
|
81
|
+
|
|
71
82
|
it("exits with the migration's status", async () => {
|
|
72
83
|
const result = await launch(["src/database/migrate.ts"], "development", "process.exit(3);\n");
|
|
73
84
|
expect(result.code).toBe(3);
|
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { Database as SqliteDatabase } from "bun:sqlite";
|
|
2
|
-
import { existsSync, readdirSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
2
|
import type { MigrationInterface, QueryRunner } from "typeorm";
|
|
3
|
+
import { loadMigrations, migrationSet } from "./migration-files";
|
|
6
4
|
|
|
7
5
|
export interface SqliteMigrationDefinition {
|
|
8
6
|
timestamp: number;
|
|
@@ -15,40 +13,8 @@ interface AppliedMigration {
|
|
|
15
13
|
name: string;
|
|
16
14
|
}
|
|
17
15
|
|
|
18
|
-
type MigrationConstructor = new () => MigrationInterface;
|
|
19
|
-
|
|
20
|
-
function isMigration(value: unknown): value is MigrationConstructor {
|
|
21
|
-
if (typeof value !== "function") return false;
|
|
22
|
-
const prototype = (value as { prototype?: unknown }).prototype;
|
|
23
|
-
if (!prototype || typeof prototype !== "object") return false;
|
|
24
|
-
const record = prototype as Record<string, unknown>;
|
|
25
|
-
return typeof record.up === "function" && typeof record.down === "function";
|
|
26
|
-
}
|
|
27
|
-
|
|
28
16
|
async function discoverMigrations(): Promise<SqliteMigrationDefinition[]> {
|
|
29
|
-
|
|
30
|
-
const source = join(process.cwd(), "src", "migrations");
|
|
31
|
-
const directory = process.env.NODE_ENV === "production" && existsSync(compiled)
|
|
32
|
-
? compiled
|
|
33
|
-
: source;
|
|
34
|
-
if (!existsSync(directory)) return [];
|
|
35
|
-
const files = readdirSync(directory)
|
|
36
|
-
.filter((file) => /^\d+-.+\.(?:js|ts)$/.test(file))
|
|
37
|
-
.sort();
|
|
38
|
-
const definitions: SqliteMigrationDefinition[] = [];
|
|
39
|
-
for (const file of files) {
|
|
40
|
-
const loaded = await import(pathToFileURL(join(directory, file)).href) as Record<string, unknown>;
|
|
41
|
-
const constructors = Object.values(loaded).filter(isMigration);
|
|
42
|
-
if (constructors.length !== 1) {
|
|
43
|
-
throw new Error(`SQLite migration ${file} must export exactly one migration class`);
|
|
44
|
-
}
|
|
45
|
-
const timestamp = Number(file.slice(0, file.indexOf("-")));
|
|
46
|
-
if (!Number.isSafeInteger(timestamp)) throw new Error(`SQLite migration ${file} has an invalid timestamp`);
|
|
47
|
-
const Constructor = constructors[0];
|
|
48
|
-
if (!Constructor) throw new Error(`SQLite migration ${file} has no migration class`);
|
|
49
|
-
definitions.push({ timestamp, migration: new Constructor() });
|
|
50
|
-
}
|
|
51
|
-
return definitions;
|
|
17
|
+
return loadMigrations(migrationSet());
|
|
52
18
|
}
|
|
53
19
|
|
|
54
20
|
function queryRunner(database: SqliteDatabase): QueryRunner {
|
|
@@ -10,8 +10,8 @@ const app = createApp({ env, services });
|
|
|
10
10
|
async function bootstrap(): Promise<void> {
|
|
11
11
|
try {
|
|
12
12
|
await services.start();
|
|
13
|
-
app.listen({ ...apiServerOptions(env), hostname:
|
|
14
|
-
process.stdout.write(`API listening on http
|
|
13
|
+
app.listen({ ...apiServerOptions(env), hostname: env.HOST, port: env.PORT });
|
|
14
|
+
process.stdout.write(`API listening on http://${env.HOST}:${env.PORT}\n`);
|
|
15
15
|
} catch (error) {
|
|
16
16
|
await services.close();
|
|
17
17
|
throw error;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Application-owned styles. The managed app.css imports this file last, so rules
|
|
3
|
+
* here come after PodoKit's theme variables and base styles. Add `@source` for
|
|
4
|
+
* packages whose built components use Tailwind classes, override variables in
|
|
5
|
+
* `:root` and `.dark`, or add `@theme` entries and base styles, for example:
|
|
6
|
+
*
|
|
7
|
+
* @source "../../../node_modules/@example/ui/dist";
|
|
8
|
+
* :root { --radius: 0.5rem; }
|
|
9
|
+
*
|
|
10
|
+
* PodoKit creates this file once and never overwrites it.
|
|
11
|
+
*/
|
|
@@ -4,4 +4,6 @@
|
|
|
4
4
|
import { ModeWatcher } from "mode-watcher";
|
|
5
5
|
</script>
|
|
6
6
|
|
|
7
|
-
<
|
|
7
|
+
<!-- `.light` on <html> in light mode lets component libraries that follow a `.dark`
|
|
8
|
+
or `.light` class, and fall back to the OS setting otherwise, match the theme. -->
|
|
9
|
+
<ModeWatcher lightClassNames={["light"]} />
|
|
@@ -9,6 +9,7 @@ type Fetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
|
|
|
9
9
|
function requestEvent(fetch: Fetch): RequestEvent {
|
|
10
10
|
return {
|
|
11
11
|
request: new Request("http://app.localhost/services", { headers: { cookie: "session=abc" } }),
|
|
12
|
+
url: new URL("http://app.localhost/services"),
|
|
12
13
|
getClientAddress: () => "203.0.113.9",
|
|
13
14
|
fetch,
|
|
14
15
|
} as unknown as RequestEvent;
|
|
@@ -50,6 +51,24 @@ describe("serverApiClient", () => {
|
|
|
50
51
|
expect(headers?.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
51
52
|
});
|
|
52
53
|
|
|
54
|
+
it("forwards the public origin only when CORS_ORIGIN lists it", async () => {
|
|
55
|
+
let headers: Headers | undefined;
|
|
56
|
+
const api = serverApiClient(requestEvent(async (_input, init) => {
|
|
57
|
+
headers = new Headers(init?.headers);
|
|
58
|
+
return Response.json({ ok: true });
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com, http://app.localhost");
|
|
62
|
+
await api.get("/site/settings");
|
|
63
|
+
expect(headers?.get("x-forwarded-host")).toBe("app.localhost");
|
|
64
|
+
expect(headers?.get("x-forwarded-proto")).toBe("http");
|
|
65
|
+
|
|
66
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com");
|
|
67
|
+
await api.get("/site/settings");
|
|
68
|
+
expect(headers?.has("x-forwarded-host")).toBe(false);
|
|
69
|
+
expect(headers?.has("x-forwarded-proto")).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
53
72
|
it("gives up on a call that outlasts BACKEND_TIMEOUT_MS", async () => {
|
|
54
73
|
vi.stubEnv("BACKEND_TIMEOUT_MS", "20");
|
|
55
74
|
stubHealth(async () => new Response("ok"));
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
reportBackendAnswer,
|
|
9
9
|
reportBackendFailure,
|
|
10
10
|
resolveClientIp,
|
|
11
|
+
setForwardedOrigin,
|
|
11
12
|
} from "./backend-proxy";
|
|
12
13
|
|
|
13
14
|
/** Thrown without contacting the API while it is known to be unreachable. */
|
|
@@ -19,7 +20,8 @@ export class BackendUnavailableError extends Error {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
// Server-side client for load functions and hooks: points at the internal
|
|
22
|
-
// backend URL and forwards the incoming request's cookies
|
|
23
|
+
// backend URL and forwards the incoming request's cookies, client address, and
|
|
24
|
+
// public origin as the /api proxy does. Each call is bounded
|
|
23
25
|
// by BACKEND_TIMEOUT_MS, so a page renders its fallbacks instead of waiting for
|
|
24
26
|
// an API that does not answer.
|
|
25
27
|
export function serverApiClient(event: RequestEvent): ApiClient {
|
|
@@ -31,6 +33,7 @@ export function serverApiClient(event: RequestEvent): ApiClient {
|
|
|
31
33
|
const headers = new Headers(init?.headers);
|
|
32
34
|
if (cookie) headers.set("cookie", cookie);
|
|
33
35
|
if (clientIp) headers.set("x-forwarded-for", clientIp);
|
|
36
|
+
setForwardedOrigin(headers, event.url);
|
|
34
37
|
const outage = backendOutageSignal();
|
|
35
38
|
const signals = [AbortSignal.timeout(backendTimeoutMs()), outage];
|
|
36
39
|
if (init?.signal) signals.push(init.signal);
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
bodyLimitFor,
|
|
8
8
|
checkBackend,
|
|
9
9
|
checkIdleTimeout,
|
|
10
|
+
configuredPublicOrigins,
|
|
10
11
|
envMilliseconds,
|
|
11
12
|
IN_MEMORY_RESPONSE_BYTES,
|
|
12
13
|
proxyRequest,
|
|
@@ -16,12 +17,14 @@ import {
|
|
|
16
17
|
resolveClientIp,
|
|
17
18
|
sortedBodyLimits,
|
|
18
19
|
sortedProxyTimeouts,
|
|
20
|
+
upstreamFetchOptions,
|
|
19
21
|
withAppHeaders,
|
|
20
22
|
} from "./backend-proxy";
|
|
21
23
|
|
|
22
24
|
describe("backend proxy client address handling", () => {
|
|
23
25
|
afterEach(() => {
|
|
24
26
|
vi.unstubAllGlobals();
|
|
27
|
+
vi.unstubAllEnvs();
|
|
25
28
|
});
|
|
26
29
|
|
|
27
30
|
it("returns the normalized address from a trusted proxy chain", () => {
|
|
@@ -74,6 +77,48 @@ describe("backend proxy client address handling", () => {
|
|
|
74
77
|
expect(upstreamHeaders?.get("x-forwarded-for")).toBe("203.0.113.9");
|
|
75
78
|
});
|
|
76
79
|
|
|
80
|
+
it("forwards the public origin a request came in on when CORS_ORIGIN lists it", async () => {
|
|
81
|
+
let upstreamHeaders: Headers | undefined;
|
|
82
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
83
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
84
|
+
return new Response(null, { status: 204 });
|
|
85
|
+
});
|
|
86
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com,https://mirror.example.com");
|
|
87
|
+
|
|
88
|
+
// The adapter builds the request URL from the gateway's forwarded host and
|
|
89
|
+
// protocol; a caller's own forwarded headers never reach the API.
|
|
90
|
+
const request = new Request("https://mirror.example.com/api/mcp/info", {
|
|
91
|
+
headers: { "x-forwarded-host": "attacker.example", "x-forwarded-proto": "http" },
|
|
92
|
+
});
|
|
93
|
+
await proxyRequest(request, "http://api:5002/mcp/info");
|
|
94
|
+
|
|
95
|
+
expect(upstreamHeaders?.get("x-forwarded-host")).toBe("mirror.example.com");
|
|
96
|
+
expect(upstreamHeaders?.get("x-forwarded-proto")).toBe("https");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("forwards no origin that CORS_ORIGIN does not list", async () => {
|
|
100
|
+
let upstreamHeaders: Headers | undefined;
|
|
101
|
+
vi.stubGlobal("fetch", async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
102
|
+
upstreamHeaders = new Headers(init?.headers);
|
|
103
|
+
return new Response(null, { status: 204 });
|
|
104
|
+
});
|
|
105
|
+
vi.stubEnv("CORS_ORIGIN", "https://example.com");
|
|
106
|
+
|
|
107
|
+
const request = new Request("http://attacker.example/api/mcp/info", {
|
|
108
|
+
headers: { "x-forwarded-host": "example.com", "x-forwarded-proto": "https" },
|
|
109
|
+
});
|
|
110
|
+
await proxyRequest(request, "http://api:5002/mcp/info");
|
|
111
|
+
|
|
112
|
+
expect(upstreamHeaders?.has("x-forwarded-host")).toBe(false);
|
|
113
|
+
expect(upstreamHeaders?.has("x-forwarded-proto")).toBe(false);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("reads absolute CORS_ORIGIN entries as origins and skips patterns", () => {
|
|
117
|
+
expect([...configuredPublicOrigins(" https://example.com/ , *.example.com, not a url, http://localhost:5001")])
|
|
118
|
+
.toEqual(["https://example.com", "http://localhost:5001"]);
|
|
119
|
+
expect(configuredPublicOrigins("").size).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
77
122
|
// Not forwarding this does not leave the header unset: fetch substitutes its own
|
|
78
123
|
// default, so the API records "node" for every session and the account page shows
|
|
79
124
|
// that as the device for all of them.
|
|
@@ -886,6 +931,40 @@ describe("backend proxy application timeouts", () => {
|
|
|
886
931
|
expect(checks).toBeGreaterThanOrEqual(3);
|
|
887
932
|
});
|
|
888
933
|
|
|
934
|
+
it("turns off Bun's fetch idle timeout on a route without a deadline only", async () => {
|
|
935
|
+
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
|
|
936
|
+
const fetch = stubApi(async () => new Response("ok"), async () => Response.json({ done: true }));
|
|
937
|
+
|
|
938
|
+
await proxy(
|
|
939
|
+
new Request("http://app.localhost/api/hosts/h1/file-transfers/t1/commit", { method: "POST", body: "{}" }),
|
|
940
|
+
"http://api:5002/hosts/h1/file-transfers/t1/commit",
|
|
941
|
+
);
|
|
942
|
+
await proxy(new Request("http://app.localhost/api/hosts/h1/notes"), "http://api:5002/hosts/h1/notes");
|
|
943
|
+
|
|
944
|
+
const upstream = fetch.mock.calls.filter(([input]) => !String(input).endsWith("/health"));
|
|
945
|
+
const [commit, notes] = upstream.map(([, init]) => init as RequestInit & { timeout?: unknown });
|
|
946
|
+
expect(upstream).toHaveLength(2);
|
|
947
|
+
expect(commit?.timeout).toBe(false);
|
|
948
|
+
expect(notes).not.toHaveProperty("timeout");
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it("turns off Bun's fetch idle timeout for a deadline it would cut first", async () => {
|
|
952
|
+
expect(upstreamFetchOptions(null)).toEqual({ timeout: false });
|
|
953
|
+
expect(upstreamFetchOptions(600_000)).toEqual({ timeout: false });
|
|
954
|
+
expect(upstreamFetchOptions(300_000)).toEqual({ timeout: false });
|
|
955
|
+
expect(upstreamFetchOptions(299_999)).toEqual({});
|
|
956
|
+
expect(upstreamFetchOptions(30_000)).toEqual({});
|
|
957
|
+
|
|
958
|
+
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "300000");
|
|
959
|
+
vi.stubEnv("CONNECTION_IDLE_TIMEOUT", "0");
|
|
960
|
+
const proxy = await proxyWithTimeouts([]);
|
|
961
|
+
const fetch = stubApi(async () => new Response("ok"), async () => Response.json({ done: true }));
|
|
962
|
+
|
|
963
|
+
await proxy(new Request("http://app.localhost/api/report"), "http://api:5002/report");
|
|
964
|
+
|
|
965
|
+
expect(fetch.mock.calls[0]?.[1]).toHaveProperty("timeout", false);
|
|
966
|
+
});
|
|
967
|
+
|
|
889
968
|
it("waits without a deadline on a route with a path parameter", async () => {
|
|
890
969
|
vi.stubEnv("BACKEND_PROXY_TIMEOUT_MS", "20");
|
|
891
970
|
const proxy = await proxyWithTimeouts([{ pathPrefix: "/api/hosts/:hostId/file-transfers", timeoutMs: null }]);
|
|
@@ -358,6 +358,23 @@ export function proxyTimeoutFor(
|
|
|
358
358
|
return entry ? entry.timeoutMs : backendProxyTimeoutMs();
|
|
359
359
|
}
|
|
360
360
|
|
|
361
|
+
/**
|
|
362
|
+
* Bun's fetch fails a request whose connection sends nothing for its idle
|
|
363
|
+
* timeout, 300 seconds unless BUN_CONFIG_HTTP_IDLE_TIMEOUT sets another, even
|
|
364
|
+
* while the proxy still waits for response headers.
|
|
365
|
+
*/
|
|
366
|
+
const FETCH_IDLE_TIMEOUT_MS = 300_000;
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Bun's fetch options for a request that waits up to timeoutMs for response
|
|
370
|
+
* headers. A wait without a deadline, or one Bun's idle timeout would cut
|
|
371
|
+
* first, turns that timeout off, so only the deadline, the health checks, and
|
|
372
|
+
* the browser end it. Shorter waits keep Bun's default.
|
|
373
|
+
*/
|
|
374
|
+
export function upstreamFetchOptions(timeoutMs: number | null): { timeout?: false } {
|
|
375
|
+
return timeoutMs === null || timeoutMs >= FETCH_IDLE_TIMEOUT_MS ? { timeout: false } : {};
|
|
376
|
+
}
|
|
377
|
+
|
|
361
378
|
/** Bun closes silent connections on a 4-second tick, up to 4 seconds before their idle timeout. */
|
|
362
379
|
const IDLE_TICK_SECONDS = 4;
|
|
363
380
|
/** The longest idle timeout Bun accepts; 0 turns it off. */
|
|
@@ -509,6 +526,40 @@ export function normalizeClientIp(address: string | undefined): string | undefin
|
|
|
509
526
|
return address;
|
|
510
527
|
}
|
|
511
528
|
|
|
529
|
+
let publicOriginsSource: string | undefined;
|
|
530
|
+
let publicOrigins: ReadonlySet<string> = new Set();
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* The public web origins listed in CORS_ORIGIN. Wildcard patterns name no single
|
|
534
|
+
* origin and are skipped, as are entries that are not absolute URLs.
|
|
535
|
+
*/
|
|
536
|
+
export function configuredPublicOrigins(value = process.env.CORS_ORIGIN): ReadonlySet<string> {
|
|
537
|
+
if (value !== publicOriginsSource) {
|
|
538
|
+
publicOriginsSource = value;
|
|
539
|
+
publicOrigins = new Set(
|
|
540
|
+
(value ?? "")
|
|
541
|
+
.split(",")
|
|
542
|
+
.map((entry) => entry.trim())
|
|
543
|
+
.filter((entry) => !entry.includes("*") && URL.canParse(entry))
|
|
544
|
+
.map((entry) => new URL(entry).origin),
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return publicOrigins;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Tell the API which public origin a request came in on. SvelteKit resolves the
|
|
552
|
+
* request URL from the Host header, or from the gateway's forwarded headers that
|
|
553
|
+
* HOST_HEADER and PROTOCOL_HEADER name, so the value is sent only when that origin
|
|
554
|
+
* is listed in CORS_ORIGIN. The API can then build links for the origin a browser
|
|
555
|
+
* used without trusting a header a caller wrote.
|
|
556
|
+
*/
|
|
557
|
+
export function setForwardedOrigin(headers: Headers, url: URL): void {
|
|
558
|
+
if (!configuredPublicOrigins().has(url.origin)) return;
|
|
559
|
+
headers.set("x-forwarded-host", url.host);
|
|
560
|
+
headers.set("x-forwarded-proto", url.protocol.slice(0, -1));
|
|
561
|
+
}
|
|
562
|
+
|
|
512
563
|
export function resolveClientIp(getClientAddress: () => string): string | undefined {
|
|
513
564
|
try {
|
|
514
565
|
return normalizeClientIp(getClientAddress());
|
|
@@ -531,8 +582,10 @@ export async function proxyRequest(
|
|
|
531
582
|
// Forward the resolved client IP so the API (better-auth) can record it.
|
|
532
583
|
const forwardedIp = normalizeClientIp(clientAddress);
|
|
533
584
|
if (forwardedIp) headers.set("x-forwarded-for", forwardedIp);
|
|
585
|
+
const requestUrl = new URL(request.url);
|
|
586
|
+
setForwardedOrigin(headers, requestUrl);
|
|
534
587
|
|
|
535
|
-
const { pathname } =
|
|
588
|
+
const { pathname } = requestUrl;
|
|
536
589
|
if (!backendReachable()) {
|
|
537
590
|
request.body?.cancel().catch(() => undefined);
|
|
538
591
|
return backendFailure(pathname, 503);
|
|
@@ -578,6 +631,7 @@ export async function proxyRequest(
|
|
|
578
631
|
// closed EventSource or aborted request keeps its API socket open until Bun's
|
|
579
632
|
// fetch pool is exhausted and the web process stops serving requests.
|
|
580
633
|
signal: AbortSignal.any([request.signal, waiting.signal]),
|
|
634
|
+
...upstreamFetchOptions(timeoutMs),
|
|
581
635
|
});
|
|
582
636
|
} catch (cause: unknown) {
|
|
583
637
|
// Nobody waits for an answer to a request the browser abandoned.
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
* headers before it answers 504 BACKEND_TIMEOUT; null waits as long as the API
|
|
11
11
|
* takes. Either way the proxy answers 503 BACKEND_UNAVAILABLE once the API's
|
|
12
12
|
* GET /health stops answering, and the deadline ends when the response headers
|
|
13
|
-
* arrive.
|
|
13
|
+
* arrive. For null or 300 seconds and more, the proxy also turns off Bun's fetch
|
|
14
|
+
* idle timeout, which would otherwise end a silent wait after 300 seconds.
|
|
14
15
|
*
|
|
15
16
|
* Bun closes a silent connection up to 4 seconds before its idle timeout, so a
|
|
16
17
|
* longer wait also needs the API's API_IDLE_TIMEOUT and the web server's
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { webSocketProxy } from "./vite.config.ts";
|
|
3
|
+
|
|
4
|
+
describe("dev-server WebSocket proxy", () => {
|
|
5
|
+
it("sends each exact path in .podokit/dev.json to the API", () => {
|
|
6
|
+
const proxy = webSocketProxy(
|
|
7
|
+
JSON.stringify({ hostname: "app.localhost", webSocketPaths: ["/events/ws", "/agent.v1/ws"] }),
|
|
8
|
+
"http://api:5002",
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
expect(proxy).toEqual({
|
|
12
|
+
"^/events/ws(?:\\?.*)?$": { target: "http://api:5002", ws: true, xfwd: true },
|
|
13
|
+
"^/agent\\.v1/ws(?:\\?.*)?$": { target: "http://api:5002", ws: true, xfwd: true },
|
|
14
|
+
});
|
|
15
|
+
const [events] = Object.keys(proxy).map((key) => new RegExp(key));
|
|
16
|
+
expect(events?.test("/events/ws")).toBe(true);
|
|
17
|
+
expect(events?.test("/events/ws?token=1")).toBe(true);
|
|
18
|
+
expect(events?.test("/events/ws/other")).toBe(false);
|
|
19
|
+
expect(events?.test("/events/wsx")).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("proxies nothing without the file or its paths", () => {
|
|
23
|
+
expect(webSocketProxy(null)).toEqual({});
|
|
24
|
+
expect(webSocketProxy(JSON.stringify({ hostname: "app.localhost" }))).toEqual({});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects a path the gateway would reject", () => {
|
|
28
|
+
expect(() => webSocketProxy(JSON.stringify({ webSocketPaths: ["events/ws"] }))).toThrow(
|
|
29
|
+
'invalid path: "events/ws"',
|
|
30
|
+
);
|
|
31
|
+
expect(() => webSocketProxy(JSON.stringify({ webSocketPaths: "/events/ws" }))).toThrow("must be an array");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
1
2
|
import adapter from "@sveltejs/adapter-bun";
|
|
2
3
|
import { sveltekit } from "@sveltejs/kit/vite";
|
|
3
4
|
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
|
|
4
5
|
import tailwindcss from "@tailwindcss/vite";
|
|
5
|
-
import { defineConfig } from "vite";
|
|
6
|
+
import { defineConfig, mergeConfig, type ProxyOptions } from "vite";
|
|
7
|
+
import { appSvelteKitConfig, appViteConfig } from "./vite.extensions.ts";
|
|
6
8
|
|
|
7
9
|
// Inside the dev container, bind to all interfaces and accept local gateway or
|
|
8
10
|
// HTTPS tunnel hostnames. Vite derives HMR's protocol, host, and port from the
|
|
@@ -10,16 +12,56 @@ import { defineConfig } from "vite";
|
|
|
10
12
|
const inDocker = process.env.VITE_DOCKER === "1";
|
|
11
13
|
const publicOrigin = process.env.PODOKIT_BUILD_ORIGIN;
|
|
12
14
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Dev-server proxies for the exact WebSocket paths in `.podokit/dev.json`. A
|
|
17
|
+
* SvelteKit route cannot answer an upgrade, so `bun run dev` sends these paths to
|
|
18
|
+
* the API the way the `podo dev` gateway and a deployment's edge do. The API must
|
|
19
|
+
* authenticate each upgrade itself.
|
|
20
|
+
*/
|
|
21
|
+
export function webSocketProxy(
|
|
22
|
+
devConfig: string | null,
|
|
23
|
+
target = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:5002",
|
|
24
|
+
): Record<string, ProxyOptions> {
|
|
25
|
+
if (devConfig === null) return {};
|
|
26
|
+
const { webSocketPaths = [] } = JSON.parse(devConfig) as { webSocketPaths?: unknown };
|
|
27
|
+
if (!Array.isArray(webSocketPaths)) throw new Error(".podokit/dev.json webSocketPaths must be an array of paths");
|
|
28
|
+
return Object.fromEntries(
|
|
29
|
+
webSocketPaths.map((path: unknown) => {
|
|
30
|
+
if (typeof path !== "string" || !/^\/[A-Za-z0-9._~!$&'()+,;=:@/-]+$/.test(path)) {
|
|
31
|
+
throw new Error(`.podokit/dev.json webSocketPaths has an invalid path: ${JSON.stringify(path)}`);
|
|
32
|
+
}
|
|
33
|
+
const exact = `^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:\\?.*)?$`;
|
|
34
|
+
return [exact, { target, ws: true, xfwd: true }];
|
|
20
35
|
}),
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readDevConfig(): string | null {
|
|
40
|
+
try {
|
|
41
|
+
return readFileSync(new URL("../../.podokit/dev.json", import.meta.url), "utf8");
|
|
42
|
+
} catch (cause) {
|
|
43
|
+
if ((cause as { code?: unknown }).code === "ENOENT") return null;
|
|
44
|
+
throw cause;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export default defineConfig((env) =>
|
|
49
|
+
mergeConfig(
|
|
50
|
+
{
|
|
51
|
+
plugins: [
|
|
52
|
+
tailwindcss(),
|
|
53
|
+
sveltekit({
|
|
54
|
+
adapter: adapter(),
|
|
55
|
+
preprocess: vitePreprocess(),
|
|
56
|
+
...appSvelteKitConfig,
|
|
57
|
+
...(publicOrigin ? { paths: { ...appSvelteKitConfig.paths, origin: publicOrigin } } : {}),
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
server: {
|
|
61
|
+
...(inDocker ? { host: true, port: 5001, allowedHosts: true } : {}),
|
|
62
|
+
...(env.command === "serve" ? { proxy: webSocketProxy(readDevConfig()) } : {}),
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
typeof appViteConfig === "function" ? appViteConfig(env) : appViteConfig,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Config as SvelteKitConfig } from "@sveltejs/kit/vite";
|
|
2
|
+
import type { UserConfig, UserConfigFnObject } from "vite";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Application-owned additions to the managed vite.config.ts, combined with Vite's
|
|
6
|
+
* `mergeConfig`: lists such as `plugins` are appended and objects are merged key
|
|
7
|
+
* by key. Use it for dev-server warmup, dependency optimization, `define`
|
|
8
|
+
* constants, extra plugins, or extra allowed hosts instead of editing
|
|
9
|
+
* vite.config.ts. A function receives Vite's `{ command, mode }`. WebSocket paths
|
|
10
|
+
* listed in `.podokit/dev.json` already reach the API through the dev server.
|
|
11
|
+
* PodoKit creates this file once and never overwrites it.
|
|
12
|
+
*/
|
|
13
|
+
export const appViteConfig: UserConfig | UserConfigFnObject = {
|
|
14
|
+
// Transform the pages most sessions open first as soon as the dev server starts:
|
|
15
|
+
// server: { warmup: { clientFiles: ["./src/routes/+layout.svelte", "./src/routes/+page.svelte"] } },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Application-owned SvelteKit options, such as `alias`, `csrf`, or `prerender`.
|
|
20
|
+
* PodoKit sets `adapter` and `preprocess`, and `paths.origin` from
|
|
21
|
+
* PODOKIT_BUILD_ORIGIN at build time.
|
|
22
|
+
*/
|
|
23
|
+
export const appSvelteKitConfig: SvelteKitConfig = {};
|