xxscreeps-mod-client 0.3.6 → 0.3.8

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.
Files changed (2) hide show
  1. package/backend.js +58 -32
  2. package/package.json +2 -2
package/backend.js CHANGED
@@ -58,17 +58,17 @@ function normalizeMount(input) {
58
58
  const mountPath = normalizeMount(process.env.SCREEPS_MOD_CLIENT_MOUNT_PATH ?? '/')
59
59
  const rootRedirect = readBool('SCREEPS_MOD_CLIENT_ROOT_REDIRECT', mountPath !== '/')
60
60
 
61
- // Paths that should never be handled by the client mod, even when mounted at '/'.
62
- // Can be overridden via SCREEPS_MOD_CLIENT_EXCLUDE (comma-separated prefixes).
63
- // Note: /assets/ is reserved by the game server; the client bundle uses /_client/ instead.
64
- // Do NOT exclude /map/ it's a client-side SPA route (/map/<shard>). xxscreeps has no
65
- // HTTP route there (map-stats is under /api/, map render assets under /assets/, the map
66
- // renderer is a websocket), and the await-next()-then-404-fallback below already leaves
67
- // any real server route untouched.
68
- const DEFAULT_EXCLUDES = ['/api/', '/socket', '/backend/', '/auth/', '/assets/']
69
- const excludePrefixes = process.env.SCREEPS_MOD_CLIENT_EXCLUDE
70
- ? process.env.SCREEPS_MOD_CLIENT_EXCLUDE.split(',').map(s => s.trim()).filter(Boolean)
71
- : DEFAULT_EXCLUDES
61
+ // Top-level client SPA routes. A path that isn't a real file in dist/ but matches
62
+ // one of these is a client-side deep link (or a reload of one) and gets the SPA
63
+ // shell; every other path is handed to xxscreeps, so its API/website routes are
64
+ // never shadowed and genuinely-unknown paths get a real 404 instead of the shell.
65
+ // Mirrors the route table in screeps-client/src/stores/routeStore.ts +
66
+ // utils/gameRoutes.ts keep in sync when the client gains a new top-level route.
67
+ const SPA_ROUTES = ['/user', '/profile', '/messages', '/market', '/room-overview', '/map', '/room']
68
+
69
+ function isSpaRoute(relPath) {
70
+ return SPA_ROUTES.some(prefix => relPath === prefix || relPath.startsWith(prefix + '/'))
71
+ }
72
72
 
73
73
  function resolveFile(relPath) {
74
74
  const rel = relPath.replace(/^\/+/, '')
@@ -90,15 +90,46 @@ function sendFile(ctx, filePath, stat) {
90
90
  ctx.body = createReadStream(filePath)
91
91
  }
92
92
 
93
- function renderInjectedIndex(filePath) {
93
+ function jsonForScript(value) {
94
+ return JSON.stringify(value).replace(/</g, '\\u003c')
95
+ }
96
+
97
+ // The client fetches `/api/version` on load (pre-login and again post-connect) to
98
+ // configure itself: welcome text, shards, history settings, and the auth/feature
99
+ // gates. Since the server already has this on hand when it renders index.html, we
100
+ // prefetch it once in-process and inline it as `window.__SCREEPS_BOOTSTRAP__`, so
101
+ // the embedded client is configured from the first frame with zero round-trips.
102
+ // Cached like the client's own 5-min version cache; failures just omit the global
103
+ // and the client falls back to its normal fetch.
104
+ const VERSION_TTL_MS = 5 * 60_000
105
+ let versionCache = null
106
+
107
+ async function bootstrapVersion(ctx) {
108
+ const now = Date.now()
109
+ if (versionCache && now < versionCache.expires) return versionCache.data
110
+ try {
111
+ const origin = `${ctx.protocol}://${ctx.host}`
112
+ const res = await fetch(`${origin}/api/version`)
113
+ if (!res.ok) return null
114
+ const data = await res.json()
115
+ versionCache = { data, expires: now + VERSION_TTL_MS }
116
+ return data
117
+ } catch (err) {
118
+ console.error('[xxscreeps-mod-client] failed to prefetch /api/version for bootstrap:', err)
119
+ return null
120
+ }
121
+ }
122
+
123
+ function renderInjectedIndex(filePath, version) {
94
124
  const mountDisplay = mountPath === '/' ? '/' : mountPath + '/'
95
125
  const baseTag = `<base href="${mountDisplay}">`
96
- const metadata = JSON.stringify({
126
+ const metadata = jsonForScript({
97
127
  kind: 'xxscreeps-mod',
98
128
  packageName: pkg.name,
99
129
  version: pkg.version,
100
- }).replace(/</g, '\\u003c')
101
- const script = `<script>window.__SCREEPS_CLIENT_EMBEDDED__=${metadata}</script>`
130
+ })
131
+ const bootstrap = version ? `;window.__SCREEPS_BOOTSTRAP__=${jsonForScript(version)}` : ''
132
+ const script = `<script>window.__SCREEPS_CLIENT_EMBEDDED__=${metadata}${bootstrap}</script>`
102
133
  let html = readFileSync(filePath, 'utf8')
103
134
  // Inject base tag first so relative asset URLs resolve from the mount root,
104
135
  // not from the current SPA route (e.g. /room/E11N2).
@@ -106,10 +137,11 @@ function renderInjectedIndex(filePath) {
106
137
  return html.includes('</head>') ? html.replace('</head>', `${script}</head>`) : html + script
107
138
  }
108
139
 
109
- function sendInjectedIndex(ctx) {
140
+ async function sendInjectedIndex(ctx) {
141
+ const version = await bootstrapVersion(ctx)
110
142
  ctx.type = 'text/html'
111
143
  ctx.set('Cache-Control', REVALIDATE_CACHE)
112
- ctx.body = renderInjectedIndex(indexFile)
144
+ ctx.body = renderInjectedIndex(indexFile, version)
113
145
  }
114
146
 
115
147
  // Advertise the server's guest/registration/Steam settings at `/api/version` so
@@ -135,9 +167,6 @@ hooks.register('middleware', koa => {
135
167
 
136
168
  const mountDisplay = mountPath === '/' ? '/' : mountPath + '/'
137
169
  console.log(`[xxscreeps-mod-client] serving client at ${mountDisplay} (rootRedirect=${rootRedirect})`)
138
- if (mountPath === '/') {
139
- console.log(`[xxscreeps-mod-client] excluded prefixes: ${excludePrefixes.join(', ')}`)
140
- }
141
170
 
142
171
  koa.use(async (ctx, next) => {
143
172
  if (ctx.method !== 'GET' && ctx.method !== 'HEAD') return next()
@@ -149,8 +178,6 @@ hooks.register('middleware', koa => {
149
178
 
150
179
  let relPath
151
180
  if (mountPath === '/') {
152
- // When mounted at root, skip paths that belong to the game server.
153
- if (excludePrefixes.some(p => ctx.path.startsWith(p))) return next()
154
181
  relPath = ctx.path
155
182
  } else if (ctx.path === mountPath || ctx.path === mountPath + '/') {
156
183
  relPath = '/'
@@ -161,7 +188,7 @@ hooks.register('middleware', koa => {
161
188
  }
162
189
 
163
190
  if (relPath === '/' || relPath === '/index.html') {
164
- sendInjectedIndex(ctx)
191
+ await sendInjectedIndex(ctx)
165
192
  return
166
193
  }
167
194
 
@@ -172,14 +199,13 @@ hooks.register('middleware', koa => {
172
199
  return
173
200
  }
174
201
 
175
- // Otherwise let xxscreeps handle the request. If it 404s and the path
176
- // looks like an SPA route (no file extension on the last segment),
177
- // fall back to index.html so client-side routing can take over.
178
- await next()
179
- if (ctx.status !== 404) return
180
- const last = relPath.split('/').pop() ?? ''
181
- if (last.includes('.')) return
182
- ctx.status = 200
183
- sendInjectedIndex(ctx)
202
+ // Not a real file: claim only the client's own SPA routes (deep links and
203
+ // reloads) with the index shell, and leave every other path to xxscreeps.
204
+ if (isSpaRoute(relPath)) {
205
+ await sendInjectedIndex(ctx)
206
+ return
207
+ }
208
+
209
+ return next()
184
210
  })
185
211
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xxscreeps-mod-client",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "xxscreeps mod that serves the screeps-client and connects it to the same server.",
5
5
  "type": "module",
6
6
  "xxscreeps": true,
@@ -23,7 +23,7 @@
23
23
  "node": ">=20"
24
24
  },
25
25
  "dependencies": {
26
- "screeps-client": "^0.17.0"
26
+ "screeps-client": "^0.18.0"
27
27
  },
28
28
  "peerDependencies": {
29
29
  "xxscreeps": "*"