diff --git a/deno.lock b/deno.lock index 4507636e..7b3d7d25 100644 --- a/deno.lock +++ b/deno.lock @@ -66,13 +66,13 @@ "jsr:@std/yaml@^1.0.5": "1.0.9", "jsr:@torm/sqlite@^1.9.7": "1.9.7", "npm:@deno/vite-plugin@1.0.6": "1.0.6_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", - "npm:@jsr/andykais__ts-rpc@~0.2.4": "0.2.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", + "npm:@jsr/andykais__ts-rpc@~0.2.4": "0.2.4_acorn@8.16.0", "npm:@jsr/torm__sqlite@^1.9.7": "1.9.7", "npm:@opentelemetry/api@1.9.0": "1.9.0", "npm:@opentelemetry/api@^1.9.0": "1.9.0", "npm:@sveltejs/kit@2.43.7": "2.43.7_@opentelemetry+api@1.9.0_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.15.0_@types+node@24.2.0", "npm:@sveltejs/kit@2.5.24": "2.5.24_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", - "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", + "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0", "npm:@tailwindcss/vite@4.1.14": "4.1.14_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "npm:@types/node@*": "24.2.0", "npm:esbuild@*": "0.25.10", @@ -80,8 +80,7 @@ "npm:sv@*": "0.12.5", "npm:sv@latest": "0.12.5", "npm:svelte-check@4.3.2": "4.3.2_svelte@5.39.8__acorn@8.15.0_typescript@5.9.3", - "npm:svelte@5.39.8": "5.39.8_acorn@8.15.0", - "npm:svelte@~5.39.8": "5.39.8_acorn@8.15.0", + "npm:svelte@5.39.8": "5.39.8", "npm:tailwindcss@4.1.14": "4.1.14", "npm:ts-expect@*": "1.3.0", "npm:ts-pattern@5.2.0": "5.2.0", @@ -522,11 +521,11 @@ "@jridgewell/sourcemap-codec" ] }, - "@jsr/andykais__ts-rpc@0.2.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0": { + "@jsr/andykais__ts-rpc@0.2.4_acorn@8.16.0": { "integrity": "sha512-q8DSNIQLpPw/z+VcVcY+rrbRsmzZmw+bwopW4u95buXRLO/Bet8uYRddroAA8AI5zZkvuHJnxlNppwUmIFU5rA==", "dependencies": [ "@jsr/oak__oak", - "@sveltejs/kit@2.5.24_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0" + "@sveltejs/kit@2.5.24_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.16.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__acorn@8.16.0_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0" ], "tarball": "https://npm.jsr.io/~/11/@jsr/andykais__ts-rpc/0.2.4.tgz" }, @@ -878,6 +877,9 @@ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" }, "@sveltejs/acorn-typescript@1.0.6_acorn@8.15.0": { + "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==" + }, + "@sveltejs/acorn-typescript@1.0.6_acorn@8.16.0": { "integrity": "sha512-4awhxtMh4cx9blePWl10HRHj8Iivtqj+2QdDCSMDzxG+XKa9+VCNupQuCuvzEhYPzZSrX+0gC+0lHA/0fFKKQQ==", "dependencies": [ "acorn" @@ -888,8 +890,8 @@ "dependencies": [ "@opentelemetry/api", "@standard-schema/spec", - "@sveltejs/acorn-typescript", - "@sveltejs/vite-plugin-svelte", + "@sveltejs/acorn-typescript@1.0.6_acorn@8.15.0", + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "@types/cookie", "acorn", "cookie", @@ -901,7 +903,7 @@ "sade", "set-cookie-parser", "sirv@3.0.2", - "svelte", + "svelte@5.39.8_acorn@8.15.0", "vite" ], "optionalPeers": [ @@ -912,7 +914,7 @@ "@sveltejs/kit@2.5.24_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0": { "integrity": "sha512-Nr2oxsCsDfEkdS/zzQQQbsPYTbu692Qs3/iE3L7VHzCVjG2+WujF9oMUozWI7GuX98KxYSoPMlAsfmDLSg44hQ==", "dependencies": [ - "@sveltejs/vite-plugin-svelte", + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0", "@types/cookie", "cookie", "devalue", @@ -924,7 +926,29 @@ "sade", "set-cookie-parser", "sirv@2.0.4", - "svelte", + "svelte@5.39.8_acorn@8.15.0", + "tiny-glob", + "vite" + ], + "scripts": true, + "bin": true + }, + "@sveltejs/kit@2.5.24_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.16.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__acorn@8.16.0_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0": { + "integrity": "sha512-Nr2oxsCsDfEkdS/zzQQQbsPYTbu692Qs3/iE3L7VHzCVjG2+WujF9oMUozWI7GuX98KxYSoPMlAsfmDLSg44hQ==", + "dependencies": [ + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0", + "@types/cookie", + "cookie", + "devalue", + "esm-env", + "import-meta-resolve", + "kleur", + "magic-string", + "mrmime", + "sade", + "set-cookie-parser", + "sirv@2.0.4", + "svelte@5.39.8_acorn@8.16.0", "tiny-glob", "vite" ], @@ -934,12 +958,12 @@ "@sveltejs/sv-utils@0.0.2": { "integrity": "sha512-vmH85dvUP5UtlSgu/dmlybSUT9ZGURkrKlLNzKJ5fO5ZmnV/kzlS3UlzlTrffqbVEkaZwEJV3RW45RK19z0vVg==" }, - "@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.15.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__@types+node@24.2.0_svelte@5.39.8__acorn@8.15.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_@types+node@24.2.0": { + "@sveltejs/vite-plugin-svelte-inspector@5.0.1_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.39.8___acorn@8.16.0__vite@7.1.8___@types+node@24.2.0___picomatch@4.0.3__acorn@8.16.0_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0": { "integrity": "sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==", "dependencies": [ - "@sveltejs/vite-plugin-svelte", + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0", "debug", - "svelte", + "svelte@5.39.8_acorn@8.16.0", "vite" ] }, @@ -950,7 +974,19 @@ "debug", "deepmerge", "magic-string", - "svelte", + "svelte@5.39.8_acorn@8.16.0", + "vite", + "vitefu" + ] + }, + "@sveltejs/vite-plugin-svelte@6.2.1_svelte@5.39.8__acorn@8.16.0_vite@7.1.8__@types+node@24.2.0__picomatch@4.0.3_acorn@8.16.0": { + "integrity": "sha512-YZs/OSKOQAQCnJvM/P+F1URotNnYNeU3P2s4oIpzm1uFaqUEqRxUB0g5ejMjEb5Gjb9/PiBI5Ktrq4rUUF8UVQ==", + "dependencies": [ + "@sveltejs/vite-plugin-svelte-inspector", + "debug", + "deepmerge", + "magic-string", + "svelte@5.39.8_acorn@8.16.0", "vite", "vitefu" ] @@ -1069,8 +1105,8 @@ "undici-types" ] }, - "acorn@8.15.0": { - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "acorn@8.16.0": { + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "bin": true }, "aria-query@5.3.2": { @@ -1397,7 +1433,7 @@ "fdir", "picocolors", "sade", - "svelte", + "svelte@5.39.8_acorn@8.15.0", "typescript" ], "bin": true @@ -1407,16 +1443,16 @@ "dependencies": [ "dedent-js", "scule", - "svelte", + "svelte@5.39.8_acorn@8.15.0", "typescript" ] }, - "svelte@5.39.8_acorn@8.15.0": { + "svelte@5.39.8": { "integrity": "sha512-KfZ3hCITdxIXTOvrea4nFZX2o+47HPTChKeocgj9BwJQYqWrviVCcPj4boXHF5yf8+eBKqhHY8xii//XaakKXA==", "dependencies": [ "@jridgewell/remapping", "@jridgewell/sourcemap-codec", - "@sveltejs/acorn-typescript", + "@sveltejs/acorn-typescript@1.0.6_acorn@8.16.0", "@types/estree", "acorn", "aria-query", @@ -1430,6 +1466,42 @@ "zimmerframe" ] }, + "svelte@5.39.8_acorn@8.15.0": { + "integrity": "sha512-KfZ3hCITdxIXTOvrea4nFZX2o+47HPTChKeocgj9BwJQYqWrviVCcPj4boXHF5yf8+eBKqhHY8xii//XaakKXA==", + "dependencies": [ + "@jridgewell/remapping", + "@jridgewell/sourcemap-codec", + "@sveltejs/acorn-typescript@1.0.6_acorn@8.15.0", + "@types/estree", + "aria-query", + "axobject-query", + "clsx", + "esm-env", + "esrap", + "is-reference", + "locate-character", + "magic-string", + "zimmerframe" + ] + }, + "svelte@5.39.8_acorn@8.16.0": { + "integrity": "sha512-KfZ3hCITdxIXTOvrea4nFZX2o+47HPTChKeocgj9BwJQYqWrviVCcPj4boXHF5yf8+eBKqhHY8xii//XaakKXA==", + "dependencies": [ + "@jridgewell/remapping", + "@jridgewell/sourcemap-codec", + "@sveltejs/acorn-typescript@1.0.6_acorn@8.16.0", + "@types/estree", + "aria-query", + "axobject-query", + "clsx", + "esm-env", + "esrap", + "is-reference", + "locate-character", + "magic-string", + "zimmerframe" + ] + }, "tailwindcss@4.1.14": { "integrity": "sha512-b7pCxjGO98LnxVkKjaZSDeNuljC4ueKUddjENJOADtubtdo8llTaJy7HwBMeLNSSo2N5QIAgklslK1+Ir8r6CA==" }, @@ -1571,10 +1643,7 @@ }, "packages/web/adapter/lib": { "dependencies": [ - "jsr:@andykais/ts-rpc@~0.2.2", - "jsr:@std/http@0.224.5", - "npm:@opentelemetry/api@1.9.0", - "npm:svelte@~5.39.8" + "jsr:@std/http@0.224.5" ] } } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 0c62cbb7..46285910 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -31,12 +31,6 @@ const cli = new cliffy.Command() logger: forager_helpers.config.web.logger, forager, config: forager_helpers.config, - kit: { - env: { - FORAGER_INSTANCE: forager as any, - FORAGER_CONFIG: forager_helpers.config as any, - } - } }) await server.init() }) @@ -115,12 +109,6 @@ const cli = new cliffy.Command() logger: forager_helpers.config.web.logger, forager, config: forager_helpers.config, - kit: { - env: { - FORAGER_INSTANCE: forager as any, - FORAGER_CONFIG: forager_helpers.config as any, - } - } }) await server.init() await server.start() diff --git a/packages/web/adapter/adapter.js b/packages/web/adapter/adapter.js index 76ec6159..aa5ba137 100644 --- a/packages/web/adapter/adapter.js +++ b/packages/web/adapter/adapter.js @@ -1,43 +1,37 @@ -import {fileURLToPath} from 'node:url'; -import * as fs from 'jsr:@std/fs'; +import * as fs from 'jsr:@std/fs' import * as path from 'jsr:@std/path' -import {build} from 'npm:esbuild'; - -async function strip_type_imports(filepath) { - let file_contents = await Deno.readTextFile(filepath) - file_contents = file_contents.replaceAll(/@type.+import\(.*[*]/g, '*') - // (flags2 & DERIVED) !== 0 && /** @type {import('#client').Derived} */ - file_contents = file_contents.replaceAll(/@param.+import\(.*[*]/g, '*') - file_contents = file_contents.replaceAll(/@param.+import\(.*/g, '') - await Deno.writeTextFile(filepath, file_contents) -} - -/** @type {import('.').default} */ -export default function (opts = {}) { - const {buildOptions = {}} = opts; +/** + * Custom SvelteKit adapter for @forager/web. + * + * As of phase 3 of the Tauri port, this adapter produces a pure static SPA + * (equivalent to @sveltejs/adapter-static with `fallback: 'index.html'`). + * The bytes-imports machinery is retained so the static assets can be + * embedded inside a `deno compile`-built CLI binary. + * + * Output layout (relative to packages/web/adapter/lib/build): + * static/ — client assets + SPA fallback (index.html) + * bytes_imports.ts — `with { type: 'bytes' }` re-exports of every static file + * build.json — manifest used by mod.ts at runtime + */ +export default function () { return { - name: 'deno-deploy-adapter', + name: 'forager-static-adapter', async adapt(builder) { const web_package_root = path.resolve(import.meta.dirname, '..') const published_package_dir = path.join(web_package_root, 'adapter', 'lib') const out = path.join(published_package_dir, 'build') - const tmp = builder.getBuildDirectory('deno-deploy-adapter'); - builder.rimraf(out); - builder.rimraf(tmp); - builder.mkdirp(tmp); + builder.rimraf(out) + builder.mkdirp(out) - const out_server_dir = path.join(out, 'server') const out_static_dir = path.join(out, 'static') const out_static_kit_dir = path.join(out_static_dir, builder.config.kit.paths.base) - builder.writeClient(out_static_kit_dir); - builder.writePrerendered(out_static_kit_dir); - builder.writeServer(out_server_dir); - - builder.copy(path.join(web_package_root, 'src/lib/server/config.ts'), path.join(out, 'config.ts')) + builder.writeClient(out_static_kit_dir) + builder.writePrerendered(out_static_kit_dir) + await builder.generateFallback(path.join(out_static_kit_dir, 'index.html')) const deno_json = JSON.parse(await Deno.readTextFile(path.join(published_package_dir, 'deno.json'))) const LOCAL_BUILD = Deno.env.get('LOCAL_BUILD') @@ -47,27 +41,17 @@ export default function (opts = {}) { const js_static_imports = {} const build_manifest = { - package_version: package_version, + package_version, APP_DIR: builder.getAppPath(), - PRERENDERED: builder.prerendered.paths, + SPA_FALLBACK: 'index.html', generated_files: {}, } + const vite_generated_build_files = await Array.fromAsync( + fs.walk(out_static_dir, { includeDirs: false }), + ) - // annoying code here, but sveltekit outputs type hints that are perhaps correct, but not resolved properly by deno, so we get checker errors https://github.com/sveltejs/kit/issues/12930 - const server_index_filepath = path.join(out_server_dir, 'index.js') - const chunks_internal_filepath = path.join(out_server_dir, 'chunks', 'internal.js') - await Promise.all([ - strip_type_imports(server_index_filepath), - strip_type_imports(chunks_internal_filepath), - ]) - - const vite_generated_build_files = [ - // ...await Array.fromAsync(fs.walk(out_server_dir, {includeDirs: false})), - ...await Array.fromAsync(fs.walk(out_static_dir, {includeDirs: false})), - ] - - for await (const file of vite_generated_build_files) { + for (const file of vite_generated_build_files) { const relative_path = path.relative(out, file.path) const file_id = relative_path.replaceAll(/[^a-zA-Z0-9]/g, '_') if (js_static_imports[file_id]) { @@ -80,39 +64,6 @@ export default function (opts = {}) { const js_static_import_file = [...Object.values(js_static_imports)].join('\n') await Deno.writeTextFile(path.join(out, 'bytes_imports.ts'), js_static_import_file) await Deno.writeTextFile(path.join(out, 'build.json'), JSON.stringify(build_manifest, null, 2)) - - /* - // transpile build/server.js and build/server/* into a single esm compatible bundle - const defaultOptions = { - entryPoints: [`${out}/server.js`], - outfile: `${out}/server.js`, - bundle: true, - format: 'esm', - target: 'esnext', - platform: 'node', - allowOverwrite: true - }; - - for (const key of Object.keys(buildOptions)) { - if (Object.hasOwn(defaultOptions, key)) { - console.warn( - `Warning: "buildOptions" has override for default "${key}" this may break deployment.` - ); - } - } - - try { - await build({ - ...defaultOptions, - ...buildOptions - }); - } catch (err) { - console.error(err); - process.exit(1); - } finally { - // builder.rimraf(`${out}/server`); - } - */ - } - }; + }, + } } diff --git a/packages/web/adapter/lib/deno.json b/packages/web/adapter/lib/deno.json index 49e464d9..3d4b33fc 100644 --- a/packages/web/adapter/lib/deno.json +++ b/packages/web/adapter/lib/deno.json @@ -8,15 +8,12 @@ }, "imports": { - "@opentelemetry/api": "npm:@opentelemetry/api@1.9.0", - "@andykais/ts-rpc": "jsr:@andykais/ts-rpc@^0.2.2", - "@std/http/file-server": "jsr:@std/http@0.224.5/file-server", - "svelte": "npm:svelte@~5.39.8" + "@std/http/file-server": "jsr:@std/http@0.224.5/file-server" }, "exports": { ".": "./mod.ts", - "./config": "./config.ts" + "./config": "./shim_config.ts" }, "publish": { @@ -24,10 +21,8 @@ "README.md", "LICENSE", "mod.ts", - "config.ts", - "server.js", - "static/**/*", - "server/**/*" + "shim_config.ts", + "build/**/*" ] } } diff --git a/packages/web/adapter/lib/mod.ts b/packages/web/adapter/lib/mod.ts index b557e707..6baa3f3d 100644 --- a/packages/web/adapter/lib/mod.ts +++ b/packages/web/adapter/lib/mod.ts @@ -1,25 +1,25 @@ -import {serveDir, serveFile, type ServeDirOptions} from '@std/http/file-server'; +import {serveDir, serveFile, type ServeDirOptions} from '@std/http/file-server' import * as path from '@std/path' import {Forager} from '@forager/core' import {Logger, type LogLevel} from '@forager/core/logger' import { ForagerServer } from '@forager/server' -import { type Config } from '@forager/server/config' -import { trace } from "@opentelemetry/api" +import { type Config, load_config } from '@forager/server/config' -// these relative imports are generated by the adapter in the build step -import * as forager_config from './build/config.ts' import build_manifest from './build/build.json' with { type: 'json' } - -import {Server as KitServer} from './build/server/index.js'; -import {manifest} from './build/server/manifest.js'; import * as static_asset_bytes from './build/bytes_imports.ts' -export const kitServer = new KitServer(manifest as any); + +/** + * @forager/web is, as of phase 3 of the Tauri port, a thin static-SPA host + * that mounts {@linkcode ForagerServer} for `/rpc/*` and `/files/*`. The + * SvelteKit runtime is gone — the SPA produced by the custom static + * adapter is what's served. Static asset bytes are baked into the JSR + * package via `with { type: 'bytes' }` imports so the whole thing fits + * inside a single `deno compile`-built CLI binary. + */ interface ServerOptions { // log level for server messages - logger?: { - level: LogLevel - } + logger?: { level: LogLevel } // the port the server is hosted on port?: number @@ -30,96 +30,64 @@ interface ServerOptions { // special flag used for development. Disables fetching of assets from jsr.io preview?: boolean - // When provided, /rpc/* and /files/* are dispatched through @forager/server - // directly, bypassing SvelteKit. SvelteKit still handles all other (HTML) - // routes. When omitted, requests fall through to SvelteKit as before. - forager?: Forager - config?: Config - - kit?: { - // the key/value pair of environment variables handed to svelte kit server - env?: Record - } + // The Forager instance and resolved config the embedded ForagerServer uses. + // Required at runtime; the embedding CLI passes them in. + forager: Forager + config: Config } + class Server { #options: ServerOptions #server?: Deno.HttpServer - #prerendered: Set #appDir: string #baseDir: string #rootDir: string - #kitServerInitialized!: Promise #logger: Logger - #forager_server?: ForagerServer + #forager_server: ForagerServer #routes: { - favicon: URLPattern immutable_asset: URLPattern - static_asset: URLPattern } #serve_dir_options: ServeDirOptions + #fallback_html_path: string constructor(options: ServerOptions) { this.#logger = new Logger('forager.web', options?.logger?.level) - this.#options = options ?? {} - this.#prerendered = new Set(build_manifest.PRERENDERED); - this.#appDir = build_manifest.APP_DIR; - this.#baseDir = path.dirname(new URL(import.meta.url).pathname); + this.#options = options + this.#appDir = build_manifest.APP_DIR + this.#baseDir = path.dirname(new URL(import.meta.url).pathname) if (options.preview) { - this.#rootDir = path.join(this.#baseDir, 'build', 'static'); + this.#rootDir = path.join(this.#baseDir, 'build', 'static') } else { - this.#rootDir = path.join(this.#options.asset_folder, build_manifest.package_version) + this.#rootDir = path.join(options.asset_folder, build_manifest.package_version) } this.#routes = { - favicon: new URLPattern({ - pathname: '/favicon.png', - }), - immutable_asset: new URLPattern({ - pathname: `/${this.#appDir}/immutable/*`, - }), - static_asset: new URLPattern({ - pathname: `/${this.#appDir}/*`, - }) + immutable_asset: new URLPattern({ pathname: `/${this.#appDir}/immutable/*` }), } this.#serve_dir_options = { fsRoot: this.#rootDir, quiet: this.#options.logger?.level !== 'DEBUG', } - if (options.forager && options.config) { - this.#forager_server = new ForagerServer({ - forager: options.forager, - config: options.config, - logger: options.logger, - }) - } + this.#fallback_html_path = path.join(this.#rootDir, build_manifest.SPA_FALLBACK) + this.#forager_server = new ForagerServer({ + forager: options.forager, + config: options.config, + logger: options.logger, + }) } async init() { await this.#ensure_static_assets_exist() - - this.#logger.debug('Initializing sveltekit server instance') - const env = this.#options?.kit?.env ?? Deno.env.toObject() - this.#kitServerInitialized = kitServer.init({ - env, - read: undefined, - - /* leaving this for future optimizations, we let sveltekit pick the default file reader, but we can wrap this however we want */ - // read: async (filepath: string) => { - // const file = await Deno.open(filepath) - // return file.readable - // } - }) - await this.#kitServerInitialized } async start(): Promise { this.#logger.debug('Starting deno http server') this.#server = Deno.serve({ - hostname: '127.0.0.1', // Only bind to localhost' + hostname: '127.0.0.1', // Only bind to localhost port: this.#options.port ?? 8000, onError: (error) => { - this.#logger.error(`An error occured on the server: ${error}`) - return new Response('Internal Error', {status: 500}) + this.#logger.error(`An error occurred on the server: ${error}`) + return new Response('Internal Error', { status: 500 }) }, onListen: (addr) => { this.#logger.info(`Listening on ${addr.hostname}:${addr.port}`) @@ -137,40 +105,33 @@ class Server { async shutdown() { if (this.#server === undefined) { - throw new Error("Server has not been started yet") + throw new Error('Server has not been started yet') } await this.#server.shutdown() } async #ensure_static_assets_exist() { - // in preview mode, we will just use the locally generated built files - if (this.#options.preview) { - return - } + if (this.#options.preview) return const static_assets_folder = path.join(this.#options.asset_folder, build_manifest.package_version) - // check if the version assets are already downloaded in the asset folder try { const dir_stats = await Deno.stat(static_assets_folder) if (!dir_stats.isDirectory) { throw new Error(`Unexpected code path. Static asset dir ${static_assets_folder} is a file, not a directory`) } - await this.#check_asset_dir_contents(static_assets_folder) return } catch (e) { if (e instanceof Deno.errors.NotFound) { - /* this is handled below */ + /* handled below */ } else { - throw e - } + throw e + } } this.#logger.debug(`Clearing away any old files in ${this.#options.asset_folder}.`) - // if it doesnt exist, just clear the whole dir away and start over - await Deno.remove(this.#options.asset_folder, {recursive: true}).catch(e => { - if (e instanceof Deno.errors.NotFound) { /* we can safely ignore this, since we're trying to remove the dir */ } - else { throw e } + await Deno.remove(this.#options.asset_folder, { recursive: true }).catch(e => { + if (e instanceof Deno.errors.NotFound) { /* ignore */ } else { throw e } }) this.#logger.debug(`Writing static assets from raw imports into folder ${static_assets_folder}`) @@ -183,120 +144,62 @@ class Server { this.#logger.debug(`Wrote ${asset_output_path}`) } - await this.#check_asset_dir_contents(static_assets_folder) this.#logger.debug(`Completed writing asset files to ${static_assets_folder}`) } - async #check_asset_dir_contents(folder: string) { - const dir_entries = await Array.fromAsync(Deno.readDir(folder)) - const dir_filenames = dir_entries.map(entry => entry.name) - if (dir_filenames.length !== 2 || !dir_filenames.includes('favicon.png') || !dir_filenames.includes('_app')) { - throw new Error(`Root dir ${this.#rootDir} does not contain expected asset files`) - } - } - - #handle_request = async (request: Request, info: Deno.ServeHandlerInfo): Promise => { - const span = trace.getActiveSpan() - if (span) { - const url_pathname = new URL(request.url).pathname - span.updateName(`${request.method} ${url_pathname}`) - } - this.#logger.debug(() => { - const endpoint = `${request.method} ${request.url}` - return endpoint - /* - const headers = `${[...request.headers.entries()].map(entry => ` ${entry[0]}: ${entry[1]}`).join('\n')}` - return `${endpoint}\n${headers}` - */ - }) - + #handle_request = async (request: Request): Promise => { const url = new URL(request.url) + this.#logger.debug(() => `${request.method} ${url.pathname}`) - /* - // Path has trailing slash - const {pathname} = url - const slashed = pathname.at(-1) === '/'; + // 1. RPC + media/thumbnail streaming via @forager/server. + const forager_response = await this.#forager_server.try_handle_request(request) + if (forager_response) return forager_response - // Handle trailing slash redirects for prerendered routes - const location = slashed ? pathname.slice(0, -1) : `${pathname}/`; - if (this.#prerendered.has(location)) { - return new Response(null, { - status: 308, - statusText: 'Permanent Redirect', - headers: { - location - } - }); - } - - // Try prerendered route with html extension - if (!slashed && !path.extname(pathname) && this.#prerendered.has(pathname)) { - this.#logger.debug(`try respond with prerendered ${pathname}.html`) - const response = await serveFile( - request, - path.join(this.#rootDir, `${pathname}.html`) - ); - if (response.ok || response.status === 304) { - return response; + // 2. Static asset on disk. + const static_response = await serveDir(request, this.#serve_dir_options) + if (static_response.ok || static_response.status === 304) { + if (this.#routes.immutable_asset.test(url)) { + static_response.headers.set('cache-control', 'public, max-age=31536000, immutable') } + return static_response } - */ - - // Try static files (ignore redirects and errors) - if (this.#routes.static_asset.test(url) || this.#routes.favicon.test(url)) { - this.#logger.debug(`respond with @std/http/file-server::serveDir`) - const response = await serveDir(request, this.#serve_dir_options) - if (response.ok || response.status === 304) { - if (this.#routes.immutable_asset.test(url)) { - response.headers.set('cache-control', 'public, max-age=31536000, immutable') - } - return response + // serveDir leaves the body attached on non-ok responses (e.g. 404). Consume + // it so we don't leak a streaming body when we fall through to the SPA shell. + await static_response.body?.cancel().catch(() => {}) + + // 3. SPA fallback — every unmatched non-API path serves the client shell. + // SvelteKit then performs client-side routing. + if (request.method === 'GET' || request.method === 'HEAD') { + const fallback = await serveFile(request, this.#fallback_html_path) + if (fallback.ok || fallback.status === 304) { + // Disable caching of the shell so client deploys roll out immediately. + fallback.headers.set('cache-control', 'no-cache') + return fallback } } - // When the embedder gave us a Forager instance, dispatch /rpc/* and - // /files/* through @forager/server directly, bypassing SvelteKit. - if (this.#forager_server) { - const forager_response = await this.#forager_server.try_handle_request(request) - if (forager_response) return forager_response - } - - this.#logger.debug(`respond with @sveltekit server`) - // Pass to the SvelteKit server - await this.#kitServerInitialized - return kitServer.respond(request, { - getClientAddress() { - // Get client IP address - return request.headers.get('x-forwarded-for') ?? info.remoteAddr.hostname; - } - }); + return new Response('Not Found', { status: 404 }) } } if (import.meta.main) { - const env: Record = {} const FORAGER_CONFIG = Deno.env.get('FORAGER_CONFIG') if (FORAGER_CONFIG === undefined) { throw new Error(`FORAGER_CONFIG environment variable must be set to run @forager/web module directly`) } - const config = await forager_config.load_config(FORAGER_CONFIG) + const config = await load_config(FORAGER_CONFIG) if (config.web.editing) { config.core.editing = config.web.editing } - env.FORAGER_CONFIG = config const forager = new Forager(config.core) forager.init() - env.FORAGER_INSTANCE = forager const server = new Server({ logger: config.web.logger, asset_folder: path.join(Deno.cwd(), 'static_assets'), preview: true, forager, config, - kit: { - env, - } }) await server.init() await server.start() @@ -304,4 +207,4 @@ if (import.meta.main) { export { Server } -export { PackagesConfig } from './build/config.ts' +export { PackagesConfig } from '@forager/server/config' diff --git a/packages/web/adapter/lib/shim_config.ts b/packages/web/adapter/lib/shim_config.ts new file mode 100644 index 00000000..20eb2660 --- /dev/null +++ b/packages/web/adapter/lib/shim_config.ts @@ -0,0 +1,8 @@ +// `@forager/server/config` owns the config schema as of Phase 3 of the +// Tauri port. This shim keeps the historical `@forager/web/config` export +// path working for downstream consumers. +export { + PackagesConfig, + load_config, + type Config, +} from '@forager/server/config' diff --git a/packages/web/src/hooks.server.ts b/packages/web/src/hooks.server.ts deleted file mode 100644 index d6b8e76f..00000000 --- a/packages/web/src/hooks.server.ts +++ /dev/null @@ -1,50 +0,0 @@ -import * as sveltekit from '@sveltejs/kit' -import { Forager } from '@forager/core' -import { env } from '$env/dynamic/private' -import { dev, building } from '$app/environment' -import { type Config, load_config, PackagesConfig } from '$lib/server/config.ts' -import { trace } from "@opentelemetry/api" - - -let forager: Forager -let config: Config - -if (!building) { - if (dev) { - if (env.FORAGER_CONFIG) { - // TODO make this dev-only - config = await load_config(env.FORAGER_CONFIG) - if (config.web.editing) { - config.core.editing = config.web.editing - } - forager = new Forager(config.core) - } else { - config = PackagesConfig.parse({ - core: {database: {folder: 'database'}, thumbnails: {folder: 'thumbnails'}, logger: {level: 'INFO'}}, - web: {asset_folder: 'static_assets', logger: {level: 'INFO'}} - }) - forager = new Forager(config.core) - } - - forager.init() - } else { - if (env.FORAGER_INSTANCE) { - forager = env.FORAGER_INSTANCE - config = env.FORAGER_CONFIG - } else { - throw new Error(`FORAGER_INSTANCE must be passed to sveltekit hooks`) - } - } -} - -export const handle: sveltekit.Handle = async ({ event, resolve }) => { - const span = trace.getActiveSpan() - if (span) { - const url_pathname = new URL(event.request.url).pathname - span.updateName(`${event.request.method} ${url_pathname}`) - } - event.locals.forager = forager - event.locals.config = config - const response = await resolve(event) - return response -} diff --git a/packages/web/src/routes/+layout.server.ts b/packages/web/src/routes/+layout.server.ts deleted file mode 100644 index 5b2e5197..00000000 --- a/packages/web/src/routes/+layout.server.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** @type {import('./$types').PageServerLoad} */ -export async function load(event) { - return { - config: event.locals.config - } -} - -export const ssr = false -export const prerender = false diff --git a/packages/web/src/routes/+layout.ts b/packages/web/src/routes/+layout.ts index 27adb2b9..991b5ed7 100644 --- a/packages/web/src/routes/+layout.ts +++ b/packages/web/src/routes/+layout.ts @@ -1,2 +1,23 @@ +import * as rpc from '@andykais/ts-rpc/client.ts' +import type { ApiSpec } from '$lib/api.ts' +import { resolve_connection } from '$lib/connection.ts' + + +/** + * Load the server config over RPC. As of phase 3 of the Tauri port the SPA + * is built with `adapter-static` and has no server-side load functions, so + * we fetch the config the same way every other API call goes out. + */ +export async function load() { + const connection = resolve_connection() + if (!connection.base_url) { + // SSR / prerender — nothing to fetch. + return { config: undefined } + } + const client = rpc.create(`${connection.base_url}/rpc/:signature`) + const config = await client.config() + return { config } +} + export const ssr = false export const prerender = false diff --git a/packages/web/src/routes/+page.server.ts b/packages/web/src/routes/+page.server.ts deleted file mode 100644 index 15d8f56a..00000000 --- a/packages/web/src/routes/+page.server.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { redirect } from '@sveltejs/kit'; - -/** @type {import('./$types').LayoutServerLoad} */ -export function load() { - // putting all the browse logic under the index folder just felt weird, so we redirect by default to that page now - redirect(307, '/browse'); -} diff --git a/packages/web/src/routes/+page.svelte b/packages/web/src/routes/+page.svelte new file mode 100644 index 00000000..fe32f177 --- /dev/null +++ b/packages/web/src/routes/+page.svelte @@ -0,0 +1,4 @@ + diff --git a/packages/web/src/routes/+page.ts b/packages/web/src/routes/+page.ts new file mode 100644 index 00000000..539df5b4 --- /dev/null +++ b/packages/web/src/routes/+page.ts @@ -0,0 +1,12 @@ +import { goto } from '$app/navigation' +import { browser } from '$app/environment' + + +// `/browse` is the real home — `/` only exists to redirect into it. Phase 3 +// dropped the SSR layer, so this used to be a `+page.server.ts` redirect. +export function load() { + if (browser) { + goto('/browse', { replaceState: true }) + } + return {} +} diff --git a/packages/web/src/routes/files/media_file/[id]/+server.ts b/packages/web/src/routes/files/media_file/[id]/+server.ts deleted file mode 100644 index 49c37364..00000000 --- a/packages/web/src/routes/files/media_file/[id]/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' -import { handle_media_file_request } from '@forager/server/handlers' -import type { ApiContext } from '@forager/server' - - -export const GET: RequestHandler = async ({ params, request, locals }) => { - const context: ApiContext = locals as unknown as ApiContext - return await handle_media_file_request(request, context, { id: params.id ?? '' }) -} diff --git a/packages/web/src/routes/files/thumbnail/[id]/+server.ts b/packages/web/src/routes/files/thumbnail/[id]/+server.ts deleted file mode 100644 index 20b9fe00..00000000 --- a/packages/web/src/routes/files/thumbnail/[id]/+server.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' -import { handle_thumbnail_request } from '@forager/server/handlers' -import type { ApiContext } from '@forager/server' - - -export const GET: RequestHandler = async ({ params, request, locals }) => { - const context: ApiContext = locals as unknown as ApiContext - return await handle_thumbnail_request(request, context, { id: params.id ?? '' }) -} diff --git a/packages/web/src/routes/rpc/[signature]/+server.ts b/packages/web/src/routes/rpc/[signature]/+server.ts deleted file mode 100644 index e3d13bfb..00000000 --- a/packages/web/src/routes/rpc/[signature]/+server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' -import { create_rpc_handler } from '@forager/server/handlers' -import type { ApiContext } from '@forager/server' - - -// Cache the rpc handler per context (forager + config) so realtime/SSE state -// can be shared across requests. In practice context is a stable singleton -// for the lifetime of the SvelteKit server. -let cached_handler: ((req: Request) => Promise) | undefined -let cached_context: ApiContext | undefined - - -export const PUT: RequestHandler = async ({ request, locals }) => { - const context: ApiContext = locals as unknown as ApiContext - if (!cached_handler || cached_context !== context) { - cached_handler = create_rpc_handler(context) - cached_context = context - } - return await cached_handler(request) -} diff --git a/packages/web/svelte.config.js b/packages/web/svelte.config.js index 02299a17..24b9e773 100644 --- a/packages/web/svelte.config.js +++ b/packages/web/svelte.config.js @@ -9,13 +9,10 @@ const config = { kit: { adapter: adapter(), - experimental: { - remoteFunctions: true, - }, prerender: { crawl: false, - entries: [] - } + entries: [] + } }, compilerOptions: { experimental: { diff --git a/packages/web/vite.config.ts b/packages/web/vite.config.ts index c7bd6c15..1d6ce1d2 100644 --- a/packages/web/vite.config.ts +++ b/packages/web/vite.config.ts @@ -48,6 +48,16 @@ export default defineConfig({ host: '127.0.0.1', // Only bind to localhost' + // Phase 3 of the Tauri port: vite dev no longer runs an in-process + // Forager (no more SvelteKit hooks). Forward backend traffic to a + // running `forager gui`/`forager serve` on FORAGER_API_URL (defaults to + // http://127.0.0.1:8000). Start that separately when iterating with + // `deno task --cwd packages/web dev`. + proxy: { + '/rpc': { target: process.env.FORAGER_API_URL ?? 'http://127.0.0.1:8000', changeOrigin: true }, + '/files': { target: process.env.FORAGER_API_URL ?? 'http://127.0.0.1:8000', changeOrigin: true }, + }, + // // NOTE that this disables hmr and hard server refresh. // // refreshing manually may be a better iterative workflow // hmr: {