Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
```
4 changes: 3 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 13 additions & 1 deletion frontend/__tests__/app/session/sessionEndpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
26 changes: 13 additions & 13 deletions frontend/src/app/session/SessionProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 (
Expand All @@ -78,7 +72,13 @@ export function SessionProvider({
const [connectionError, setConnectionError] = useState<string | null>(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(() => {
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/app/session/sessionEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions frontend/vercel.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"buildCommand": "npx expo export --platform web",
"outputDirectory": "dist",
"installCommand": "npm ci",
"framework": null,
"rewrites": [
{
"source": "/:path*",
"destination": "/"
}
]
}
Loading