playcademy 0.16.10 → 0.16.11

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.
package/dist/cli.js CHANGED
@@ -2965,7 +2965,7 @@ import { join as join13 } from "path";
2965
2965
  // package.json with { type: 'json' }
2966
2966
  var package_default2 = {
2967
2967
  name: "playcademy",
2968
- version: "0.16.9",
2968
+ version: "0.16.10",
2969
2969
  type: "module",
2970
2970
  exports: {
2971
2971
  ".": {
@@ -8,6 +8,7 @@ import { cors } from 'hono/cors'
8
8
 
9
9
  import { PlaycademyClient, verifyGameToken } from '@playcademy/sdk/server'
10
10
 
11
+ import { resolveRawSelfWorker, wrapSelfWorkerWithPathResolution } from '../lib/self-dispatch'
11
12
  import { populateProcessEnv, reconstructSecrets } from './setup'
12
13
 
13
14
  import type { Context, Hono } from 'hono'
@@ -47,6 +48,7 @@ export function registerEnvSetup(app: Hono<HonoEnv>, config: RuntimeConfig): voi
47
48
  app.use('*', async (c, next) => {
48
49
  populateProcessEnv(c.env)
49
50
  c.env.secrets = reconstructSecrets(c.env)
51
+ c.env.SELF = wrapSelfWorkerWithPathResolution(resolveRawSelfWorker(c.env), c.req.url)
50
52
  c.set('config', config)
51
53
  c.set('routeMetadata', config.__routeMetadata || [])
52
54
  await next()
@@ -1,2 +1,3 @@
1
1
  export * from './errors'
2
+ export * from './self-dispatch'
2
3
  export * from './validation'
@@ -0,0 +1,190 @@
1
+ import { beforeEach, describe, expect, it, mock } from 'bun:test'
2
+
3
+ import {
4
+ normalizeSelfFetchInput,
5
+ resolveRawSelfWorker,
6
+ wrapSelfWorkerWithPathResolution,
7
+ } from './self-dispatch'
8
+
9
+ import type { HonoEnv, SelfWorker } from '../types'
10
+
11
+ describe('normalizeSelfFetchInput', () => {
12
+ const requestUrl = 'https://vocabulon-staging.playcademy.gg/api/admin/genai/sprint'
13
+
14
+ it('resolves root-relative paths to the current origin', () => {
15
+ const input = normalizeSelfFetchInput('/api/internal/process-batch', requestUrl)
16
+ expect(input).toBeInstanceOf(URL)
17
+ expect((input as URL).toString()).toBe(
18
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
19
+ )
20
+ })
21
+
22
+ it('treats bare paths as root-relative', () => {
23
+ const input = normalizeSelfFetchInput('api/internal/process-batch', requestUrl)
24
+ expect(input).toBeInstanceOf(URL)
25
+ expect((input as URL).toString()).toBe(
26
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
27
+ )
28
+ })
29
+
30
+ it('preserves absolute URL strings', () => {
31
+ const input = normalizeSelfFetchInput(
32
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
33
+ requestUrl,
34
+ )
35
+ expect(input).toBeInstanceOf(URL)
36
+ expect((input as URL).toString()).toBe(
37
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
38
+ )
39
+ })
40
+
41
+ it('preserves query and hash on normalized paths', () => {
42
+ const input = normalizeSelfFetchInput('/api/internal/process-batch?attempt=2#step', requestUrl)
43
+ expect(input).toBeInstanceOf(URL)
44
+ expect((input as URL).toString()).toBe(
45
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process-batch?attempt=2#step',
46
+ )
47
+ })
48
+
49
+ it('passes through URL objects unchanged', () => {
50
+ const url = new URL('https://vocabulon-staging.playcademy.gg/api/internal/process-batch')
51
+ const input = normalizeSelfFetchInput(url, requestUrl)
52
+ expect(input).toBe(url)
53
+ })
54
+
55
+ it('supports non-http absolute schemes', () => {
56
+ const input = normalizeSelfFetchInput('mailto:test@example.com', requestUrl)
57
+ expect(input).toBeInstanceOf(URL)
58
+ expect((input as URL).toString()).toBe('mailto:test@example.com')
59
+ })
60
+
61
+ it('does not allow protocol-relative input to escape origin', () => {
62
+ const input = normalizeSelfFetchInput('//evil.example.com/path', requestUrl)
63
+ expect(input).toBeInstanceOf(URL)
64
+ expect((input as URL).toString()).toBe('https://vocabulon-staging.playcademy.gg/evil.example.com/path')
65
+ })
66
+
67
+ it('passes through Request objects unchanged', () => {
68
+ const request = new Request('https://vocabulon-staging.playcademy.gg/api/internal/process-batch')
69
+ const input = normalizeSelfFetchInput(request, requestUrl)
70
+ expect(input).toBe(request)
71
+ })
72
+ })
73
+
74
+ describe('wrapSelfWorkerWithPathResolution', () => {
75
+ const requestUrl = 'https://vocabulon-staging.playcademy.gg/api/admin/genai/sprint'
76
+
77
+ it('normalizes string path inputs before delegating to raw self worker', async () => {
78
+ const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
79
+ const raw: SelfWorker = {
80
+ fetch: (input, init) => {
81
+ calls.push({ input, init })
82
+ return Promise.resolve(new Response(null, { status: 202 }))
83
+ },
84
+ }
85
+
86
+ const self = wrapSelfWorkerWithPathResolution(raw, requestUrl)
87
+ const init = { method: 'POST' }
88
+ const response = await self.fetch('api/internal/process-batch', init)
89
+
90
+ expect(response.status).toBe(202)
91
+ expect(calls).toHaveLength(1)
92
+ expect(calls[0]?.input).toBeInstanceOf(URL)
93
+ expect((calls[0]?.input as URL).toString()).toBe(
94
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process-batch',
95
+ )
96
+ expect(calls[0]?.init).toEqual(init)
97
+ })
98
+
99
+ it('passes Request objects through to the raw self worker', async () => {
100
+ const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = []
101
+ const raw: SelfWorker = {
102
+ fetch: (input, init) => {
103
+ calls.push({ input, init })
104
+ return Promise.resolve(new Response(null, { status: 204 }))
105
+ },
106
+ }
107
+ const self = wrapSelfWorkerWithPathResolution(raw, requestUrl)
108
+ const req = new Request('https://vocabulon-staging.playcademy.gg/api/internal/process-batch')
109
+
110
+ await self.fetch(req)
111
+
112
+ expect(calls).toHaveLength(1)
113
+ expect(calls[0]?.input).toBe(req)
114
+ expect(calls[0]?.init).toBeUndefined()
115
+ })
116
+ })
117
+
118
+ describe('resolveRawSelfWorker', () => {
119
+ beforeEach(() => {
120
+ mock.restore()
121
+ })
122
+
123
+ it('uses dispatch namespace binding when available', async () => {
124
+ const targetWorker: SelfWorker = {
125
+ fetch: () => Promise.resolve(new Response('ok')),
126
+ }
127
+
128
+ const env = {
129
+ __PLAYCADEMY_DISPATCH: {
130
+ get: (name: string) => {
131
+ expect(name).toBe('staging-vocabulon')
132
+ return targetWorker
133
+ },
134
+ },
135
+ __PLAYCADEMY_WORKER_NAME: 'staging-vocabulon',
136
+ } as unknown as HonoEnv['Bindings']
137
+
138
+ const resolved = resolveRawSelfWorker(env)
139
+ const response = await resolved.fetch('https://vocabulon-staging.playcademy.gg/api/internal/process')
140
+ expect(response.status).toBe(200)
141
+ })
142
+
143
+ it('falls back to global fetch when dispatch binding is unavailable', async () => {
144
+ const fetchMock = mock(() => Promise.resolve(new Response('fallback', { status: 204 })))
145
+ const originalFetch = globalThis.fetch
146
+ globalThis.fetch = fetchMock as unknown as typeof fetch
147
+
148
+ try {
149
+ const env = {} as HonoEnv['Bindings']
150
+ const resolved = resolveRawSelfWorker(env)
151
+ const response = await resolved.fetch(
152
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process',
153
+ )
154
+
155
+ expect(response.status).toBe(204)
156
+ expect(fetchMock).toHaveBeenCalledTimes(1)
157
+ const firstCall = fetchMock.mock.calls[0] as unknown[] | undefined
158
+ expect(firstCall).toBeDefined()
159
+ const arg = firstCall?.[0]
160
+ expect(arg).toBeInstanceOf(Request)
161
+ } finally {
162
+ globalThis.fetch = originalFetch
163
+ }
164
+ })
165
+
166
+ it('falls back to global fetch when worker name is missing', async () => {
167
+ const fetchMock = mock(() => Promise.resolve(new Response('fallback', { status: 206 })))
168
+ const originalFetch = globalThis.fetch
169
+ globalThis.fetch = fetchMock as unknown as typeof fetch
170
+
171
+ try {
172
+ const env = {
173
+ __PLAYCADEMY_DISPATCH: {
174
+ get: () => {
175
+ throw new Error('should not be called when worker name is missing')
176
+ },
177
+ },
178
+ } as unknown as HonoEnv['Bindings']
179
+ const resolved = resolveRawSelfWorker(env)
180
+ const response = await resolved.fetch(
181
+ 'https://vocabulon-staging.playcademy.gg/api/internal/process',
182
+ )
183
+
184
+ expect(response.status).toBe(206)
185
+ expect(fetchMock).toHaveBeenCalledTimes(1)
186
+ } finally {
187
+ globalThis.fetch = originalFetch
188
+ }
189
+ })
190
+ })
@@ -0,0 +1,41 @@
1
+ import type { HonoEnv, SelfWorker } from '../types'
2
+
3
+ function isAbsoluteUrl(value: string): boolean {
4
+ return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value)
5
+ }
6
+
7
+ export function normalizeSelfFetchInput(
8
+ input: RequestInfo | URL,
9
+ requestUrl: string,
10
+ ): RequestInfo | URL {
11
+ if (typeof input !== 'string') {
12
+ return input
13
+ }
14
+
15
+ if (isAbsoluteUrl(input)) {
16
+ return new URL(input)
17
+ }
18
+
19
+ const origin = new URL(requestUrl).origin
20
+ const sanitized = input.startsWith('//') ? `/${input.replace(/^\/+/, '')}` : input
21
+ const pathname = sanitized.startsWith('/') ? sanitized : `/${sanitized}`
22
+
23
+ return new URL(pathname, origin)
24
+ }
25
+
26
+ export function wrapSelfWorkerWithPathResolution(raw: SelfWorker, requestUrl: string): SelfWorker {
27
+ return {
28
+ fetch: (input, init) => raw.fetch(normalizeSelfFetchInput(input, requestUrl), init),
29
+ }
30
+ }
31
+
32
+ export function resolveRawSelfWorker(env: HonoEnv['Bindings']): SelfWorker {
33
+ const dispatchBinding = env.__PLAYCADEMY_DISPATCH
34
+ const workerName = env.__PLAYCADEMY_WORKER_NAME
35
+
36
+ if (dispatchBinding && workerName) {
37
+ return dispatchBinding.get(workerName)
38
+ }
39
+
40
+ return { fetch: (input, init) => fetch(new Request(input, init)) }
41
+ }
@@ -17,6 +17,21 @@ import type { RouteMetadata } from './entry/types'
17
17
  */
18
18
  export type AssetsFetcher = { fetch(request: Request): Promise<Response> }
19
19
 
20
+ /**
21
+ * Internal self-dispatch worker fetcher.
22
+ * Exposed to games as env.SELF for background request chaining.
23
+ */
24
+ export type SelfWorker = {
25
+ fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>
26
+ }
27
+
28
+ /**
29
+ * Dispatch namespace binding used to resolve the current worker.
30
+ */
31
+ export type DispatchNamespaceBinding = {
32
+ get(name: string): SelfWorker
33
+ }
34
+
20
35
  /**
21
36
  * Enabled integrations from playcademy.config.js
22
37
  */
@@ -60,6 +75,15 @@ export interface ServerEnv {
60
75
  /** R2 bucket binding (optional, Cloudflare-specific) */
61
76
  BUCKET?: R2Bucket
62
77
 
78
+ /** Self-dispatch API for internal loopback requests */
79
+ SELF?: SelfWorker
80
+
81
+ /** Internal dispatch namespace binding (hydrated to SELF by middleware) */
82
+ __PLAYCADEMY_DISPATCH?: DispatchNamespaceBinding
83
+
84
+ /** Internal worker name for self-resolution in dispatch namespace */
85
+ __PLAYCADEMY_WORKER_NAME?: string
86
+
63
87
  /** Allow dynamic secret bindings (secrets_KEY_NAME) */
64
88
  [key: string]: unknown
65
89
  }
package/dist/index.js CHANGED
@@ -1357,7 +1357,7 @@ function ensureEnvironment(env) {
1357
1357
  }
1358
1358
  return getEnvironment();
1359
1359
  }
1360
- function requireEnvironment(env, hostname) {
1360
+ function requireEnvironment(env, usage) {
1361
1361
  if (env) {
1362
1362
  return ensureEnvironment(env);
1363
1363
  }
@@ -1365,12 +1365,12 @@ function requireEnvironment(env, hostname) {
1365
1365
  if (envVar) {
1366
1366
  return ensureEnvironment(envVar);
1367
1367
  }
1368
- const example = hostname || "play.example.com";
1368
+ const command = usage || "<command> [args]";
1369
1369
  logger.error("Missing required --env flag");
1370
1370
  logger.newLine();
1371
1371
  logger.admonition("tip", "Usage", [
1372
- `\`playcademy domain add ${example} --env production\``,
1373
- `\`playcademy domain add ${example} --env staging\``,
1372
+ `\`playcademy ${command} --env production\``,
1373
+ `\`playcademy ${command} --env staging\``,
1374
1374
  "",
1375
1375
  "Or set `PLAYCADEMY_ENV` in your environment."
1376
1376
  ]);
@@ -4016,7 +4016,7 @@ import { join as join12 } from "path";
4016
4016
  // package.json with { type: 'json' }
4017
4017
  var package_default2 = {
4018
4018
  name: "playcademy",
4019
- version: "0.16.9",
4019
+ version: "0.16.10",
4020
4020
  type: "module",
4021
4021
  exports: {
4022
4022
  ".": {
@@ -11220,7 +11220,7 @@ var getStatusCommand = new Command13("status").description("Check your developer
11220
11220
  });
11221
11221
 
11222
11222
  // package.json
11223
- var version2 = "0.16.9";
11223
+ var version2 = "0.16.10";
11224
11224
 
11225
11225
  // src/commands/dev/server.ts
11226
11226
  function setupCleanupHandlers(workspace, getServer) {
@@ -14627,7 +14627,7 @@ async function runDomainAdd(hostname, options) {
14627
14627
  process.exit(1);
14628
14628
  }
14629
14629
  const normalizedHostname = hostname.trim().toLowerCase();
14630
- requireEnvironment(options.env, normalizedHostname);
14630
+ const environment = requireEnvironment(options.env, `domain add ${normalizedHostname}`);
14631
14631
  await requireConfigFile();
14632
14632
  const config = await loadConfig();
14633
14633
  const slug = getSlugFromConfig(config);
@@ -14643,7 +14643,7 @@ async function runDomainAdd(hostname, options) {
14643
14643
  displayDomainValidationRecords(domain, normalizedHostname);
14644
14644
  logger.admonition("tip", "Next Steps", [
14645
14645
  "1. Add the DNS records shown above to your domain registrar",
14646
- `2. Run \`playcademy domain verify ${normalizedHostname} --env ${options.env}\` to check validation status`,
14646
+ `2. Run \`playcademy domain verify ${normalizedHostname} --env ${environment}\` to check validation status`,
14647
14647
  "",
14648
14648
  "Note: DNS propagation can take up to 24 hours. SSL certificates",
14649
14649
  "are provisioned automatically once DNS records are verified.",
@@ -14665,7 +14665,7 @@ import { confirm as confirm16 } from "@inquirer/prompts";
14665
14665
  async function runDomainDelete(hostname, options) {
14666
14666
  try {
14667
14667
  logger.newLine();
14668
- requireEnvironment(options.env, hostname);
14668
+ requireEnvironment(options.env, `domain delete ${hostname}`);
14669
14669
  await requireConfigFile();
14670
14670
  const config = await loadConfig();
14671
14671
  const slug = getSlugFromConfig(config);
@@ -14707,7 +14707,7 @@ async function runDomainList(options) {
14707
14707
  if (!options.raw && !options.json) {
14708
14708
  logger.newLine();
14709
14709
  }
14710
- requireEnvironment(options.env);
14710
+ requireEnvironment(options.env, "domain list");
14711
14711
  await requireConfigFile();
14712
14712
  const config = await loadConfig();
14713
14713
  const slug = getSlugFromConfig(config);
@@ -14757,7 +14757,7 @@ async function runDomainVerify(hostname, options) {
14757
14757
  if (!options.json) {
14758
14758
  logger.newLine();
14759
14759
  }
14760
- requireEnvironment(options.env, hostname);
14760
+ const environment = requireEnvironment(options.env, `domain verify ${hostname}`);
14761
14761
  await requireConfigFile();
14762
14762
  const config = await loadConfig();
14763
14763
  const slug = getSlugFromConfig(config);
@@ -14783,7 +14783,9 @@ async function runDomainVerify(hostname, options) {
14783
14783
  logger.newLine();
14784
14784
  } else if (domain.status === "pending" || domain.status === "pending_validation" || domain.sslStatus === "pending_validation") {
14785
14785
  logger.admonition("tip", "Still Pending", [
14786
- "DNS records can take up to 24 hours to propagate."
14786
+ "DNS records can take up to 24 hours to propagate.",
14787
+ "",
14788
+ `Run \`playcademy domain verify ${hostname} --env ${environment}\` again to check status.`
14787
14789
  ]);
14788
14790
  logger.newLine();
14789
14791
  }
@@ -15,7 +15,8 @@ declare global {
15
15
  interface PlaycademyEnv {
16
16
  PLAYCADEMY_API_KEY: string
17
17
  GAME_ID: string
18
- PLAYCADEMY_BASE_URL: string{{BINDINGS}}{{SECRETS}}
18
+ PLAYCADEMY_BASE_URL: string
19
+ SELF: { fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> }{{BINDINGS}}{{SECRETS}}
19
20
  }{{VARIABLES}}
20
21
 
21
22
  type Context = HonoContext<{ Bindings: PlaycademyEnv{{CONTEXT_VARS}} }>
package/dist/utils.js CHANGED
@@ -2358,7 +2358,7 @@ import { join as join12 } from "path";
2358
2358
  // package.json with { type: 'json' }
2359
2359
  var package_default2 = {
2360
2360
  name: "playcademy",
2361
- version: "0.16.9",
2361
+ version: "0.16.10",
2362
2362
  type: "module",
2363
2363
  exports: {
2364
2364
  ".": {
package/dist/version.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // package.json with { type: 'json' }
2
2
  var package_default = {
3
3
  name: "playcademy",
4
- version: "0.16.9",
4
+ version: "0.16.10",
5
5
  type: "module",
6
6
  exports: {
7
7
  ".": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "playcademy",
3
- "version": "0.16.10",
3
+ "version": "0.16.11",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {