diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0778564..d64eab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - uses: actions/setup-node@v4 if: steps.detect.outputs.frontend == 'true' with: - node-version: 20.20.2 + node-version: 22 cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index bfd2897..9db69d0 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -39,7 +39,7 @@ jobs: if: github.event.action != 'closed' uses: actions/setup-node@v4 with: - node-version: 20.20.2 + node-version: 22 cache: npm cache-dependency-path: frontend/package-lock.json diff --git a/README.md b/README.md index aafa9c8..a2a4ba8 100644 --- a/README.md +++ b/README.md @@ -1 +1,35 @@ # TimeFlow + +## Web PR previews (Vercel) + +Fork and same-repo pull requests can get an interactive web preview via Vercel. + +### One-time setup (org admin) + +1. Open [vercel.com](https://vercel.com) and sign in with GitHub. +2. **Add New Project** → import `1024XEngineer/timeflow`. +3. Set: + - **Root Directory**: `frontend` + - **Framework Preset**: Other + - **Build Command**: `npx expo export --platform web` (or leave default from `frontend/vercel.json`) + - **Output Directory**: `dist` + - **Install Command**: `npm ci` + - **Node.js Version**: `22.x` +4. Add **Preview** env vars (do not set these on Production unless you intend a fake demo deploy): + - `EXPO_PUBLIC_USE_FAKE_WS=true` — explicit opt-in; required because `expo export` builds with `__DEV__=false` + - `EXPO_PUBLIC_WS_URL=` (empty) — leave unset so the preview uses FakeWsServer + - Optional: `EXPO_PUBLIC_BAIDU_MAP_AK` if map picker should work in the preview +5. Approve the Vercel GitHub App for the `1024XEngineer` org if prompted. +6. Fork PR deployments are gated by Vercel **Git Fork Protection** (Settings → Security): + - Default: a Vercel team member must **authorize each fork PR deployment** (especially when the PR changes code or `vercel.json`). Expect a Deployments comment/request, not a fully automatic Preview URL on every external PR. + - Optional (higher risk): disable Git Fork Protection only after auditing every Preview environment variable for secrets; then fork PRs can deploy without per-PR approval. + +After setup, same-repo PRs get Preview URLs automatically. Fork PRs get a Preview URL after the authorization step above (or after Fork Protection is disabled). The existing GitHub Pages workflow only deploys same-repository branches. + +### Local check + +```bash +cd frontend +npm ci +EXPO_PUBLIC_USE_FAKE_WS=true EXPO_PUBLIC_WS_URL= npx expo export --platform web +``` diff --git a/frontend/.env.example b/frontend/.env.example index 9ecc315..c1c12e3 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -13,5 +13,7 @@ EXPO_PUBLIC_BAIDU_MAP_AK=your-baidu-map-browser-ak EXPO_PUBLIC_WS_URL= -# Force the fake WebSocket even if EXPO_PUBLIC_WS_URL is set (dev only). +# Explicit fake WebSocket opt-in when EXPO_PUBLIC_WS_URL is empty. +# Required for hosted web previews (`expo export` sets __DEV__=false). +# Do not set this on real production deploys. # EXPO_PUBLIC_USE_FAKE_WS=true diff --git a/frontend/__tests__/app/session/sessionEndpoint.test.ts b/frontend/__tests__/app/session/sessionEndpoint.test.ts index eeeab4c..b89caad 100644 --- a/frontend/__tests__/app/session/sessionEndpoint.test.ts +++ b/frontend/__tests__/app/session/sessionEndpoint.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from '@jest/globals'; -import { buildSessionWebSocketUrl, resolveSessionUserId } from '@/app/session/sessionEndpoint'; +import { + buildSessionWebSocketUrl, + resolveAllowFakeWs, + resolveSessionUserId, +} from '@/app/session/sessionEndpoint'; describe('session endpoint compatibility', () => { it('adds the persisted device id to the backend WebSocket URL', () => { @@ -33,4 +37,12 @@ describe('session endpoint compatibility', () => { expect(resolveSessionUserId(undefined)).toBe('default_user'); expect(resolveSessionUserId(' user_1 ')).toBe('user_1'); }); + + it('allows an explicit fake-ws opt-in outside __DEV__ for hosted previews', () => { + expect(resolveAllowFakeWs('true', false)).toBe(true); + expect(resolveAllowFakeWs('1', false)).toBe(true); + expect(resolveAllowFakeWs('false', false)).toBe(false); + expect(resolveAllowFakeWs(undefined, false)).toBe(false); + expect(resolveAllowFakeWs(undefined, true)).toBe(true); + }); }); diff --git a/frontend/package.json b/frontend/package.json index bb4965c..b932870 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "main": "index.ts", "engines": { - "node": ">=20.20.2 <21", + "node": ">=22 <23", "npm": ">=10.8.2 <11" }, "packageManager": "npm@10.8.2", diff --git a/frontend/src/app/session/SessionProvider.tsx b/frontend/src/app/session/SessionProvider.tsx index 027ed40..f2cdd34 100644 --- a/frontend/src/app/session/SessionProvider.tsx +++ b/frontend/src/app/session/SessionProvider.tsx @@ -14,7 +14,11 @@ import { FakeWsServer } from '@/dev/fakes/FakeWsServer'; import { getOrCreateDeviceId, type DeviceIdStore } from '@/infrastructure/storage/deviceIdStore'; import { WsClient } from '@/infrastructure/ws/WsClient'; -import { buildSessionWebSocketUrl, resolveSessionUserId } from './sessionEndpoint'; +import { + buildSessionWebSocketUrl, + resolveAllowFakeWs, + resolveSessionUserId, +} from './sessionEndpoint'; export type SessionTransportMode = 'remote' | 'fake' | 'unavailable'; @@ -38,21 +42,11 @@ function resolveWsUrl(): string | null { return fromEnv || null; } -function resolveAllowFake(): boolean { - const flag = - typeof process !== 'undefined' ? process.env.EXPO_PUBLIC_USE_FAKE_WS?.trim() : undefined; - const isDevBuild = typeof __DEV__ !== 'undefined' && __DEV__; - // Fake 只能进入开发/调试构建;release 即使误带变量也必须拒绝。 - if (!isDevBuild) return false; - if (flag === '0' || flag === 'false') return false; - return flag === '1' || flag === 'true' || flag == null; -} - const RECONNECT_BASE_MS = 1000; const RECONNECT_MAX_MS = 30_000; const SESSION_READY_TIMEOUT_MS = 10_000; const UNAVAILABLE_CONNECTION_ERROR = - '缺少 EXPO_PUBLIC_WS_URL。开发环境可设置 EXPO_PUBLIC_USE_FAKE_WS=true 使用进程内 Fake。'; + '缺少 EXPO_PUBLIC_WS_URL。可设置 EXPO_PUBLIC_USE_FAKE_WS=true 使用进程内 Fake(含托管预览)。'; function isSessionReady(message: WsJsonMessage, deviceId: string): message is SessionReady { return ( @@ -78,7 +72,13 @@ export function SessionProvider({ const [connectionError, setConnectionError] = useState(null); const url = useMemo(() => resolveWsUrl(), []); - const allowFake = useMemo(() => resolveAllowFake(), []); + const allowFake = useMemo( + () => + resolveAllowFakeWs( + typeof process !== 'undefined' ? process.env.EXPO_PUBLIC_USE_FAKE_WS : undefined, + ), + [], + ); const transportMode: SessionTransportMode = url ? 'remote' : allowFake ? 'fake' : 'unavailable'; const remoteEndpoint = useMemo(() => { diff --git a/frontend/src/app/session/sessionEndpoint.ts b/frontend/src/app/session/sessionEndpoint.ts index 23a7dee..9084b79 100644 --- a/frontend/src/app/session/sessionEndpoint.ts +++ b/frontend/src/app/session/sessionEndpoint.ts @@ -4,6 +4,23 @@ function isDevelopmentBuild(): boolean { return typeof __DEV__ !== 'undefined' && __DEV__; } +/** + * Decide whether the in-process FakeWsServer may be used when EXPO_PUBLIC_WS_URL is empty. + * + * - Explicit `true`/`1`: deliberate opt-in for any build (hosted web previews). + * - Explicit `false`/`0`: always disabled. + * - Unset: allowed only in `__DEV__` builds so store/production releases stay remote-only. + */ +export function resolveAllowFakeWs( + flag: string | undefined, + isDevBuild: boolean = isDevelopmentBuild(), +): boolean { + const normalized = flag?.trim(); + if (normalized === '0' || normalized === 'false') return false; + if (normalized === '1' || normalized === 'true') return true; + return isDevBuild; +} + export function buildSessionWebSocketUrl( baseUrl: string, deviceId: string, diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..13cf064 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,12 @@ +{ + "buildCommand": "npx expo export --platform web", + "outputDirectory": "dist", + "installCommand": "npm ci", + "framework": null, + "rewrites": [ + { + "source": "/:path*", + "destination": "/" + } + ] +}