index.ts 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. /**
  2. * @deepseek-ai/dsh-frontend-static — SPA dist server over the webserver
  3. * fallback seat: serves the built frontend directory with the semantics the
  4. * Web shell locked at step1 — traversal outside the dist root is 403, any
  5. * miss falls back to index.html with HTTP 200 (SPA routing), unknown
  6. * extensions ship as octet-stream, non-GET/HEAD is 405. Every index response
  7. * runs through the webserver's registered index taps (boot-manifest
  8. * injection). The dist location is workspace knowledge of the composing
  9. * application, so `distIndex` is typically supplied through a `!!js`
  10. * expression, never hardcoded by a deployment.
  11. * @module @deepseek-ai/dsh-frontend-static
  12. */
  13. import type { ServerResponse } from 'node:http'
  14. import { readFile } from 'node:fs/promises'
  15. import { dirname, extname, join, normalize, resolve, sep } from 'node:path'
  16. import type { Context } from 'cordis'
  17. import z from 'schemastery'
  18. import type {} from '@deepseek-ai/dsh-host-webserver'
  19. /** Stable Cordis plugin name. */
  20. export const name = 'frontend-static'
  21. /** Service required before the fallback seat can be claimed. */
  22. export const inject = ['httpServer']
  23. /** Plugin config: the dist anchor. */
  24. export interface Config {
  25. /** Absolute path of index.html inside the dist root. */
  26. distIndex: string
  27. }
  28. export const Config: z<Config> = z.object({
  29. distIndex: z.string().required(),
  30. })
  31. const MIME: Record<string, string> = {
  32. '.html': 'text/html; charset=utf-8',
  33. '.js': 'text/javascript; charset=utf-8',
  34. '.css': 'text/css; charset=utf-8',
  35. '.svg': 'image/svg+xml',
  36. '.json': 'application/json',
  37. '.map': 'application/json',
  38. '.webmanifest': 'application/manifest+json',
  39. }
  40. /**
  41. * Serve one GET/HEAD static request from the dist root.
  42. * @param pathname - decoded URL pathname of the request.
  43. * @param res - the node:http response to write.
  44. * @param distRoot - absolute dist root directory (resolved by the caller).
  45. * @param distIndex - absolute path of index.html inside distRoot.
  46. * @param renderIndex - produces the index.html body (index-tap injection) for
  47. * `/` and every SPA fallback.
  48. */
  49. export async function serveStatic(
  50. pathname: string, res: ServerResponse, distRoot: string, distIndex: string,
  51. renderIndex: () => Promise<string>,
  52. ): Promise<void> {
  53. const target = resolve(normalize(join(distRoot, pathname)))
  54. // Traversal rejection: the target must be distRoot itself (`/`) or stay under
  55. // it. `sep`, not '/': resolve() emits backslash paths on Windows, where a '/'
  56. // suffix would reject every legitimate subpath as traversal.
  57. if (target !== distRoot && !target.startsWith(distRoot + sep)) {
  58. res.writeHead(403)
  59. res.end()
  60. return
  61. }
  62. const serveIndex = async (): Promise<void> => {
  63. const body = await renderIndex()
  64. res.writeHead(200, { 'content-type': MIME['.html'] })
  65. res.end(body)
  66. }
  67. if (target === distRoot || target === distIndex) {
  68. await serveIndex()
  69. return
  70. }
  71. try {
  72. const body = await readFile(target)
  73. res.writeHead(200, { 'content-type': MIME[extname(target)] ?? 'application/octet-stream' })
  74. res.end(body)
  75. } catch {
  76. // Miss (ENOENT/EISDIR) falls back to index.html with 200 (SPA routing).
  77. await serveIndex()
  78. }
  79. }
  80. /**
  81. * Claim the webserver fallback seat and serve the dist.
  82. * @param ctx - plugin context carrying the httpServer service.
  83. * @param config - validated {@link Config}.
  84. */
  85. export function apply(ctx: Context, config: Config): void {
  86. const distIndex = config.distIndex
  87. const distRoot = dirname(distIndex)
  88. const renderIndex = async (): Promise<string> =>
  89. ctx.httpServer.applyIndexTaps(await readFile(distIndex, 'utf8'))
  90. ctx.effect(() => ctx.httpServer.registerFallback(async (req, res) => {
  91. // Non-GET/HEAD without a matching named route is 405 (fallback-only
  92. // semantics: named routes own their method handling).
  93. if (req.method !== 'GET' && req.method !== 'HEAD') {
  94. res.writeHead(405)
  95. res.end()
  96. return
  97. }
  98. /* v8 ignore next -- node:http always sets url on server requests */
  99. const rawPath = new URL(req.url ?? '/', 'http://x').pathname
  100. await serveStatic(decodeURIComponent(rawPath), res, distRoot, distIndex, renderIndex)
  101. }), 'frontend-static: fallback seat')
  102. }