Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
[submodule "packages/svelte-core"]
path = packages/svelte-core
url = https://github.com/OpenShock/svelte-core.git
branch = master
16 changes: 10 additions & 6 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,9 @@
"@lucide/svelte": "^1.48.0",
"@microsoft/signalr": "^10.0.11",
"@playwright/test": "^1.63.0",
"@sveltejs/adapter-cloudflare": "^7.2.9",
"@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.70.3",
"@sveltejs/adapter-cloudflare": "8.0.0",
"@sveltejs/adapter-node": "6.0.0",
"@sveltejs/kit": "3.0.0",
"@sveltejs/vite-plugin-svelte": "^7.3.1",
"@tailwindcss/vite": "^4.3.3",
"@tanstack/svelte-table": "^9.2.4",
Expand Down Expand Up @@ -65,13 +65,13 @@
"tailwindcss": "^4.3.3",
"tw-animate-css": "^1.4.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.70.1",
"typescript-eslint": "^8.71.0",
"ua-parser-js": "2.0.10",
"vaul-svelte": "1.0.0-next.7",
"vite-plugin-devtools-json": "^1.1.0",
"vite-plugin-mkcert": "^2.1.0",
"vitest": "^5.0.2",
"wrangler": "^4.141.0"
"wrangler": "^4.143.0"
},
"dependencies": {
"@openshock/svelte-core": "workspace:*",
Expand All @@ -96,5 +96,9 @@
"browserslist": [
"defaults and fully supports es6-module"
],
"packageManager": "pnpm@12.5.1"
"packageManager": "pnpm@12.5.1",
"imports": {
"#lib": "./src/lib/index.js",
"#lib/*": "./src/lib/*"
}
}
1,228 changes: 359 additions & 869 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ minimumReleaseAge: 4320 # 3 days
minimumReleaseAgeExcludePrune: true
minimumReleaseAgeExclude:
- '@openshock/*'
- '@sveltejs/adapter-cloudflare@8.0.0'
- '@sveltejs/adapter-node@6.0.0'
- '@sveltejs/kit@3.0.0'

onlyBuiltDependencies:
- '@tailwindcss/oxide'
Expand Down
2 changes: 1 addition & 1 deletion src/app.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/// <reference types="temporal-polyfill/global" />
import type { TurnstileInstance } from '$lib/types/TurnstileInstance';
import type { TurnstileInstance } from '#lib/types/TurnstileInstance.js';

// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
Expand Down
238 changes: 238 additions & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
import { defineEnvVars } from '@sveltejs/kit/env';
// `isTruthy` is the schema for every boolean variable below: it accepts `true` / `1` / `yes` / `y` /
// `on` case-insensitively and treats everything else, including unset, as `false`. That preserves
// the behaviour these variables had when they were read through `$env/dynamic/*` before the
// SvelteKit 3 migration, where consumers fed them to `isTruthy()` or compared against `'true'`.
//
// It is imported straight from the submodule source rather than through
// `@openshock/svelte-core/utils` because SvelteKit evaluates this module in a plain Node context to
// resolve `static` values, and that barrel reaches `clipboard.svelte.ts` -> `svelte-sonner` -> a
// `.svelte` file, which Node cannot load. Reaching the file directly keeps one definition and skips
// the barrel.
import { isTruthy } from '../packages/svelte-core/src/lib/utils/parse';

/** Required free text, trimmed. Throws when unset or blank. */
function text(label: string) {
return (value: string | undefined): string => {
const trimmed = value?.trim() ?? '';
if (!trimmed) throw new Error(`${label} must be set to a non-empty value`);
return trimmed;
};
}

/** Optional free text, trimmed. Unset or blank becomes `''`. */
function optionalText(value: string | undefined): string {
return value?.trim() ?? '';
}

/**
* Parses an absolute `http:` / `https:` URL and returns it as a trimmed string rather than a
* `URL` instance. Strings stay inlinable for `static` variables and devalue-serializable for
* dynamic public ones, and every consumer already builds its own `URL` from these.
*
* @param strict - Additionally require `https:` and reject query strings and hash fragments.
*/
function parseUrl(label: string, value: string, strict: boolean): string {
let url: URL;
try {
url = new URL(value);
} catch (error) {
throw new Error(`${label} is not a valid absolute URL: ${value}`, { cause: error });
}

if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new Error(`${label} must be an http: or https: URL, got ${url.protocol}`);
}

if (strict) {
if (url.protocol !== 'https:') {
throw new Error(`${label} must be an HTTPS URL`);
}
if (url.search || url.hash) {
throw new Error(`${label} must not contain query parameters or hash fragments`);
}
}

return value.trim();
}

/**
* Required absolute URL.
*
* `strict` mirrors the checks `getApiUrl()` in `#lib/utils/url.ts` performs. They are duplicated
* deliberately: validating here fails the build or boot immediately on a misconfigured
* deployment, while the copies in `url.ts` stay covered by `url.test.ts`, which mocks
* `$app/env/public` and so never runs these schemas.
*/
function url(label: string, { strict = false } = {}) {
return (value: string | undefined): string => {
const trimmed = value?.trim() ?? '';
if (!trimmed) throw new Error(`${label} must be set to an absolute URL`);
return parseUrl(label, trimmed, strict);
};
}

/** Optional absolute URL. Unset or blank becomes `''` so consumers can `||` to a fallback. */
function optionalUrl(label: string) {
return (value: string | undefined): string => {
const trimmed = value?.trim() ?? '';
if (!trimmed) return '';
return parseUrl(label, trimmed, false);
};
}

export const variables = defineEnvVars({
// --- Site identity -------------------------------------------------------------------------
PUBLIC_SITE_NAME: {
public: true,
static: true,
description: 'Display name of the deployment, used in page titles and metadata.',
schema: text('PUBLIC_SITE_NAME'),
},
PUBLIC_SITE_DESCRIPTION: {
public: true,
static: true,
description: 'One-sentence description of the deployment, used in metadata and llms.txt.',
schema: text('PUBLIC_SITE_DESCRIPTION'),
},
PUBLIC_SITE_URL: {
public: true,
static: true,
description:
"Public origin of the deployment. Its pathname also determines kit's `base` path, which " +
'`vite.config.ts` derives separately at build time.',
schema: url('PUBLIC_SITE_URL'),
},
PUBLIC_SITE_SHORT_URL: {
public: true,
static: true,
description: 'Origin of the short-link domain used for share links.',
schema: url('PUBLIC_SITE_SHORT_URL'),
},
PUBLIC_GITHUB_PROJECT_URL: {
public: true,
static: true,
description: 'Project source URL, shown in the footer, header and the X-Source-Code header.',
schema: url('PUBLIC_GITHUB_PROJECT_URL'),
},
PUBLIC_DISCORD_INVITE_URL: {
public: true,
static: true,
description: 'Community Discord invite, linked from the header, footer and help dialog.',
schema: url('PUBLIC_DISCORD_INVITE_URL'),
},

// --- Backend -------------------------------------------------------------------------------
PUBLIC_BACKEND_API_URL: {
public: true,
static: true,
description:
'Base URL of the OpenShock API. Must be HTTPS with no query string or fragment, since ' +
'paths are appended to it.',
schema: url('PUBLIC_BACKEND_API_URL', { strict: true }),
},
PRIVATE_BACKEND_TLS_INSECURE: {
description:
"INSECURE. Disables TLS certificate validation for the server's own outgoing requests, " +
'allowing SSR/API calls to a backend using a self-signed certificate. Only for ' +
'development or an isolated self-hosted network; leave unset in production. Read at ' +
'runtime so it can be set as a container env var without rebuilding.',
schema: isTruthy,
},

// --- Feature switches ----------------------------------------------------------------------
PUBLIC_DEVELOPMENT_BANNER: {
public: true,
static: true,
description: 'Shows the "development deployment" banner above the app shell.',
schema: isTruthy,
},
PUBLIC_DISABLE_ONBOARDING: {
public: true,
static: true,
description: 'Suppresses the first-run onboarding tour.',
schema: isTruthy,
},
PUBLIC_DISABLE_SHOCKER_MAP: {
public: true,
static: true,
description:
'Hides the shocker map control module. Static, so the map code is tree-shaken out of the ' +
'client bundle when this is set.',
schema: isTruthy,
},
PUBLIC_TURNSTILE_DEV_BYPASS_VALUE: {
public: true,
static: true,
description:
'Token handed to callers in place of a real Turnstile solve when the widget is bypassed ' +
'in development. Must be a value the backend rejects in production.',
schema: text('PUBLIC_TURNSTILE_DEV_BYPASS_VALUE'),
},

// --- Crawlers and metadata routes ----------------------------------------------------------
// Dynamic rather than static so a deployment can flip them without a rebuild.
PUBLIC_DISABLE_SITEMAP: {
public: true,
description: 'Makes /sitemap.xml return 404 and drops its reference from /robots.txt.',
schema: isTruthy,
},
PUBLIC_DENY_ROBOTS: {
public: true,
description: 'Serves a blanket disallow from /robots.txt.',
schema: isTruthy,
},
PUBLIC_DISABLE_LLMS_TXT: {
public: true,
description: 'Makes /llms.txt return 404.',
schema: isTruthy,
},

// --- Telemetry (SigNoz / OpenTelemetry) ----------------------------------------------------
PUBLIC_SIGNOZ_LOGS_ENABLED: {
public: true,
static: true,
description:
'Deployment kill-switch for every telemetry signal, not just logs. With this off, no ' +
'consent prompt is shown and nothing is shipped.',
schema: isTruthy,
},
PUBLIC_SIGNOZ_LOGS_URL: {
public: true,
static: true,
description: 'OTLP logs endpoint. Defaults to the collector origin + /v1/logs when left empty.',
schema: optionalUrl('PUBLIC_SIGNOZ_LOGS_URL'),
},
PUBLIC_SIGNOZ_TRACES_URL: {
public: true,
static: true,
description:
'OTLP traces endpoint for API-call tracing. Defaults to the logs collector origin + ' +
'/v1/traces when left empty.',
schema: optionalUrl('PUBLIC_SIGNOZ_TRACES_URL'),
},
PUBLIC_SIGNOZ_TRACE_PROPAGATION: {
public: true,
static: true,
description:
'Propagates W3C trace context (traceparent/tracestate) to the backend API. Requires the ' +
'backend CORS config to allow those request headers, so off by default.',
schema: isTruthy,
},
PUBLIC_SIGNOZ_DEPLOYMENT_ENVIRONMENT: {
public: true,
static: true,
description:
'Value of the `deployment.environment` resource attribute. Falls back to the build mode ' +
'(development/production) when left empty.',
schema: optionalText,
},
PUBLIC_SIGNOZ_RESOURCE_ATTRIBUTES: {
public: true,
static: true,
description:
'Extra OTel resource attributes as comma-separated key=value pairs, matching the standard ' +
'OTEL_RESOURCE_ATTRIBUTES format. Example: deployment.region=eu,team=frontend',
schema: optionalText,
},
});
46 changes: 29 additions & 17 deletions src/hooks.client.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import type { HandleClientError } from '@sveltejs/kit/hooks';
import 'temporal-polyfill/global';

import { base } from '$app/paths';
import { versionGetBackendInfo } from '$lib/api';
import { handleApiError } from '$lib/errorhandling/apiErrorHandling';
import { authState, startAuthLifecycle } from '$lib/state/auth-state.svelte';
import { backendMetadata } from '$lib/state/backend-metadata-state.svelte';
import { userState } from '$lib/state/user-state.svelte';
import { initTelemetry, log } from '$lib/telemetry/logger';
import { redirectLegacyHashRoute } from '$lib/utils/legacy-hash-redirect';
import { versionGetBackendInfo } from '#lib/api/index.js';
import { handleApiError } from '#lib/errorhandling/apiErrorHandling.js';
import { authState, startAuthLifecycle } from '#lib/state/auth-state.svelte.js';
import { backendMetadata } from '#lib/state/backend-metadata-state.svelte.js';
import { userState } from '#lib/state/user-state.svelte.js';
import { initTelemetry, log } from '#lib/telemetry/logger.js';
import { redirectLegacyHashRoute } from '#lib/utils/legacy-hash-redirect.js';
import { basePath } from '#lib/utils/url.js';
import { initializeColorScheme } from '@openshock/svelte-core/state/color-scheme-state.svelte.js';
import type { HandleClientError } from '@sveltejs/kit';

/** Best-effort extraction of a message + stack from an unknown thrown value. */
function describeError(value: unknown): { message: string; stack?: string } {
Expand Down Expand Up @@ -75,21 +75,33 @@ async function clientInit(): Promise<void> {
export async function init() {
initTelemetry();
registerGlobalErrorCapture();
redirectLegacyHashRoute(base);
// `basePath()` has no trailing slash — the mapped target already starts with one.
redirectLegacyHashRoute(basePath());
await clientInit().catch(handleApiError);
initializeColorScheme();
}

/**
* SvelteKit routes errors thrown during rendering, load, and navigation here instead of to
* window.onerror — so forward them to telemetry too, otherwise they're silently swallowed.
*
* Since SvelteKit 3 this also receives *expected* errors: `error(...)` thrown by our own code
* (`kind: 'app'`) and SvelteKit's own 404s and the like (`kind: 'framework'`). Those are normal
* responses rather than defects, so they are logged at warn level and only unexpected throws
* are reported as errors.
*/
export const handleError: HandleClientError = ({ error, event, status, message }) => {
const { message: errMessage, stack } = describeError(error);
log.error(errMessage, {
stack,
status,
route: event.route?.id ?? undefined,
kitMessage: message,
export const handleError: HandleClientError = ({ kind, error, event }) => {
const route = event.route?.id ?? undefined;

if (kind === 'unknown') {
const { message, stack } = describeError(error);
log.error(message, { stack, status: 500, route, kind });
return;
}

log.warn(error.message, {
status: kind === 'framework' ? error.status : undefined,
route,
kind,
});
};
8 changes: 4 additions & 4 deletions src/hooks.server.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { env } from '$env/dynamic/private';
import { PUBLIC_GITHUB_PROJECT_URL } from '$env/static/public';
import type { Handle, ServerInit } from '@sveltejs/kit';
import { PRIVATE_BACKEND_TLS_INSECURE } from '$app/env/private';
import { PUBLIC_GITHUB_PROJECT_URL } from '$app/env/public';
import type { Handle, ServerInit } from '@sveltejs/kit/hooks';

/**
* Security + metadata headers for every dynamically rendered response.
Expand Down Expand Up @@ -58,7 +58,7 @@ export const handle: Handle = async ({ event, resolve }) => {
export const init: ServerInit = () => {
// `typeof process` guard: on the Cloudflare adapter there is no Node `process`
// and no self-signed-cert scenario, so this is a no-op there.
if (env.PRIVATE_BACKEND_TLS_INSECURE === 'true' && typeof process !== 'undefined') {
if (PRIVATE_BACKEND_TLS_INSECURE && typeof process !== 'undefined') {
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
console.warn(
'[hooks.server] PRIVATE_BACKEND_TLS_INSECURE=true — TLS certificate validation is DISABLED for ' +
Expand Down
Loading
Loading