diff --git a/bun.lock b/bun.lock index 3ec792db019..8641b1f1393 100644 --- a/bun.lock +++ b/bun.lock @@ -1,10 +1,10 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "appwrite-website", "dependencies": { + "@appwrite.io/specs": "github:appwrite/specs#095d750d7036626d949279ddb7cd9510b902a13f", "@sentry/sveltekit": "^10.28.0", "@statsig/js-client": "^3.33.0", "@statsig/session-replay": "^3.33.0", @@ -17,7 +17,6 @@ "@appwrite.io/console": "^0.6.4", "@appwrite.io/pink": "~0.26.0", "@appwrite.io/pink-icons": "~0.26.0", - "@appwrite.io/specs": "github:appwrite/specs#095d750d7036626d949279ddb7cd9510b902a13f", "@eslint/compat": "^1.4.1", "@eslint/js": "^9.39.1", "@fingerprintjs/fingerprintjs": "^4.6.2", @@ -95,7 +94,6 @@ "typescript-eslint": "^8.48.1", "vaul-svelte": "1.0.0-next.7", "vite": "^7.2.7", - "vite-plugin-dynamic-import": "^1.6.0", "vite-plugin-image-optimizer": "^2.0.3", "vite-plugin-manifest-sri": "^0.2.0", "vitest": "^3.2.4", @@ -2102,8 +2100,6 @@ "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "6.7.14", "debug": "4.4.1", "es-module-lexer": "1.7.0", "pathe": "2.0.3", "vite": "npm:rolldown-vite@7.1.19" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], - "vite-plugin-dynamic-import": ["vite-plugin-dynamic-import@1.6.0", "", { "dependencies": { "acorn": "8.15.0", "es-module-lexer": "1.7.0", "fast-glob": "3.3.3", "magic-string": "0.30.18" } }, "sha512-TM0sz70wfzTIo9YCxVFwS8OA9lNREsh+0vMHGSkWDTZ7bgd1Yjs5RV8EgB634l/91IsXJReg0xtmuQqP0mf+rg=="], - "vite-plugin-image-optimizer": ["vite-plugin-image-optimizer@2.0.3", "", { "dependencies": { "ansi-colors": "^4.1.3", "pathe": "^2.0.3" }, "peerDependencies": { "sharp": ">=0.34.0", "svgo": ">=4", "vite": ">=5" }, "optionalPeers": ["sharp", "svgo"] }, "sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A=="], "vite-plugin-manifest-sri": ["vite-plugin-manifest-sri@0.2.0", "", {}, "sha512-Zt5jt19xTIJ91LOuQTCtNG7rTFc5OziAjBz2H5NdCGqaOD1nxrWExLhcKW+W4/q8/jOPCg/n5ncYEQmqCxiGQQ=="], diff --git a/package.json b/package.json index 1481b6658df..b11170f4bbe 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@statsig/session-replay": "^3.33.0", "@statsig/statsig-node-core": "^0.19.3", "@statsig/web-analytics": "^3.33.0", + "@appwrite.io/specs": "github:appwrite/specs#095d750d7036626d949279ddb7cd9510b902a13f", "h3": "^1.15.4", "sharp": "^0.34.5" }, @@ -39,7 +40,6 @@ "@appwrite.io/console": "^0.6.4", "@appwrite.io/pink": "~0.26.0", "@appwrite.io/pink-icons": "~0.26.0", - "@appwrite.io/specs": "github:appwrite/specs#095d750d7036626d949279ddb7cd9510b902a13f", "@eslint/compat": "^1.4.1", "@eslint/js": "^9.39.1", "@fingerprintjs/fingerprintjs": "^4.6.2", @@ -117,7 +117,6 @@ "typescript-eslint": "^8.48.1", "vaul-svelte": "1.0.0-next.7", "vite": "^7.2.7", - "vite-plugin-dynamic-import": "^1.6.0", "vite-plugin-image-optimizer": "^2.0.3", "vite-plugin-manifest-sri": "^0.2.0", "vitest": "^3.2.4", diff --git a/scripts/build.js b/scripts/build.js index 664d43a5a20..b0917286ddb 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -1,7 +1,25 @@ import { build } from 'vite'; +import { cp, mkdir } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; async function main() { await build(); + + // Copy @appwrite.io/specs data into the build output so it ships with the + // deployment archive and is reachable at runtime even in environments that + // don't include node_modules (e.g. Appwrite Sites runtime, where only the + // build artifact is mounted). + const require = createRequire(import.meta.url); + const specsRoot = dirname(require.resolve('@appwrite.io/specs/package.json')); + const projectRoot = dirname(fileURLToPath(import.meta.url)).replace(/\/scripts$/, ''); + const target = resolve(projectRoot, 'build/_specs_data'); + + await mkdir(target, { recursive: true }); + await cp(resolve(specsRoot, 'specs'), resolve(target, 'specs'), { recursive: true }); + await cp(resolve(specsRoot, 'examples'), resolve(target, 'examples'), { recursive: true }); + console.log('[build] copied specs data to', target); } main(); diff --git a/src/routes/docs/references/[version]/[platform]/[service]/specs.ts b/src/routes/docs/references/[version]/[platform]/[service]/specs.ts index 2bd2ef87fc3..f52b36ec7b9 100644 --- a/src/routes/docs/references/[version]/[platform]/[service]/specs.ts +++ b/src/routes/docs/references/[version]/[platform]/[service]/specs.ts @@ -1,6 +1,62 @@ import { error } from '@sveltejs/kit'; import { OpenAPIV3 } from 'openapi-types'; -import { Platform, type ServiceValue, type Version } from '$lib/utils/references'; +import { Platform, VALID_PLATFORMS, versions, type ServiceValue } from '$lib/utils/references'; +import { existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Spec data location. In environments where `@appwrite.io/specs` is installed +// (local dev, Docker prod with `bun install --production`), resolve via Node +// module resolution. In environments where only the build artifact ships +// (Appwrite Sites runtime), fall back to `_specs_data/` copied next to the +// server bundle by `scripts/build.js`. +function locateSpecsRoot(): string { + try { + const fromNodeModules = dirname( + createRequire(import.meta.url).resolve('@appwrite.io/specs/package.json') + ); + if (existsSync(join(fromNodeModules, 'specs'))) { + return fromNodeModules; + } + } catch { + // package not installed at runtime; fall through to bundled data + } + + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + resolve(here, '../../_specs_data'), + resolve(here, '../_specs_data'), + resolve(process.cwd(), '_specs_data') + ]; + for (const candidate of candidates) { + if (existsSync(join(candidate, 'specs'))) { + return candidate; + } + } + throw new Error('Unable to locate @appwrite.io/specs data'); +} + +const specsRoot = locateSpecsRoot(); + +// URL segments reach this module from SvelteKit route params, so anything +// interpolated into a filesystem path needs an explicit allowlist check. +const VALID_VERSIONS = new Set(versions as readonly string[]); + +function assertValidVersion(version: string): void { + if (!VALID_VERSIONS.has(version)) { + throw error(404, `Unknown spec version ${version}`); + } +} + +function assertValidPlatform(platform: string): void { + if (!VALID_PLATFORMS.has(platform as Platform)) { + throw error(404, `Unknown platform ${platform}`); + } +} + +const apiCache = new Map(); export type SDKMethod = { 'rate-limit': number; @@ -90,62 +146,15 @@ export const ModelType = { type ModelTypeType = keyof typeof ModelType; type ModelTypeValue = (typeof ModelType)[ModelTypeType]; -type ExampleVersion = Exclude; -type ExampleLoaders = Record Promise>; - -const examplesByVersion: Record = { - '0.15.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/0.15.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.0.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.0.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.1.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.1.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.2.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.2.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.3.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.3.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.4.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.4.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.5.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.5.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.6.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.6.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.7.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.7.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.8.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.8.x/**/*.md', { - query: '?raw', - import: 'default' - }), - '1.9.x': import.meta.glob('/node_modules/@appwrite.io/specs/examples/1.9.x/**/*.md', { - query: '?raw', - import: 'default' - }) -}; - -function getExamples(version: string) { - if (!(version in examplesByVersion)) { - return undefined; +async function loadExample(relativePath: string): Promise { + try { + return await readFile(join(specsRoot, relativePath), 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + return null; + } + throw e; } - - return examplesByVersion[version as ExampleVersion]; } function stripMarkdownCodeFence(content: string): string { @@ -396,23 +405,36 @@ export function getSchema(id: string, api: OpenAPIV3.Document): OpenAPIV3.Schema error(404, { message: `Not found` }); } -const specs = import.meta.glob('/node_modules/@appwrite.io/specs/specs/*/open-api3*.json', { - exhaustive: true -}); - export async function getApi(version: string, platform: string): Promise { + // Only `version` reaches the filesystem path here; `platform` is collapsed to + // a fixed `mode` literal (server/client/console). Platform validation lives + // in `getService`, where the raw value is interpolated into the example path. + assertValidVersion(version); + + const cacheKey = `${version}|${platform}`; + const cached = apiCache.get(cacheKey); + if (cached) { + return cached; + } + const isClient = platform.startsWith('client-'); const mode = platform.startsWith('server-') ? 'server' : isClient ? 'client' : 'console'; const filename = `open-api3-${version}-${mode}.json`; - - const loader = Object.entries(specs).find(([key]) => key.endsWith(`/${filename}`))?.[1]; - - if (!loader) { - throw error(404, `Missing OpenAPI spec loader for ${filename}`); + const specPath = join(specsRoot, 'specs', version, filename); + + let raw: string; + try { + raw = await readFile(specPath, 'utf8'); + } catch (e) { + if ((e as NodeJS.ErrnoException).code === 'ENOENT') { + throw error(404, `Missing OpenAPI spec ${filename}`); + } + throw e; } - const loaded = (await loader()) as OpenAPIV3.Document | { default: OpenAPIV3.Document }; - return ('default' in loaded ? loaded.default : loaded) as OpenAPIV3.Document; + const parsed = JSON.parse(raw) as OpenAPIV3.Document; + apiCache.set(cacheKey, parsed); + return parsed; } const descriptions = import.meta.glob('./descriptions/*.md', { @@ -441,6 +463,10 @@ export async function getService( }; methods: SDKMethod[]; }> { + // `platform` is interpolated into example paths below, so validate against + // the public allowlist before any filesystem access. + assertValidPlatform(platform); + /** * Exceptions for Android SDK. */ @@ -460,13 +486,7 @@ export async function getService( methods: [] }; - const examples = getExamples(version); - - if (!examples) { - return data; - } - - for (const { method, value, url } of iterateAllMethods(api, service)) { + const prepared = Array.from(iterateAllMethods(api, service)).map(({ method, value, url }) => { const operation = value as AppwriteOperationObject; const parameters = getParameters(operation); const responses: SDKMethod['responses'] = Object.entries(operation.responses ?? {}).map( @@ -507,22 +527,28 @@ export async function getService( } ); - const path = isAndroid - ? `/node_modules/@appwrite.io/specs/examples/${version}/${ + const examplePath = isAndroid + ? `examples/${version}/${ isAndroidServer ? 'server-kotlin' : 'client-android' }/${isAndroidJava ? 'java' : 'kotlin'}/${operation['x-appwrite']?.demo}` - : `/node_modules/@appwrite.io/specs/examples/${version}/${platform}/examples/${operation['x-appwrite']?.demo}`; + : `examples/${version}/${platform}/examples/${operation['x-appwrite']?.demo}`; - if (!(path in examples)) { + return { method, value: operation, url, parameters, responses, examplePath }; + }); + + const demos = await Promise.all(prepared.map((p) => loadExample(p.examplePath))); + + for (let i = 0; i < prepared.length; i++) { + const demo = demos[i]; + if (demo === null) { continue; } - - const demo = (await examples[path]()) as unknown as string; + const { method, value: operation, url, parameters, responses } = prepared[i]; data.methods.push({ id: operation['x-appwrite'].method, group: operation['x-appwrite'].group, - demo: typeof demo === 'string' ? stripMarkdownCodeFence(demo) : '', + demo: stripMarkdownCodeFence(demo), title: operation.summary ?? '', description: operation.description ?? '', parameters: parameters ?? [], diff --git a/vite.config.ts b/vite.config.ts index bbc1820f9a5..5d6d5568758 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,5 @@ import { enhancedImages } from '@sveltejs/enhanced-img'; import { sveltekit } from '@sveltejs/kit/vite'; -import dynamicImport from 'vite-plugin-dynamic-import'; import { ViteImageOptimizer } from 'vite-plugin-image-optimizer'; import manifestSRI from 'vite-plugin-manifest-sri'; import { defineConfig } from 'vitest/config'; @@ -21,13 +20,6 @@ export default defineConfig({ // }), enhancedImages(), sveltekit(), - dynamicImport({ - filter(id) { - if (id.includes('/node_modules/@appwrite.io/specs/examples')) { - return true; - } - } - }), ViteImageOptimizer({ include: ['**/*.png', '**/*.jpg', '**/*.jpeg', '**/*.gif', '**/*.svg'], exclude: ['**/*.avif', '**/*.webp'],