Skip to content

Commit a1445fe

Browse files
committed
feat(session-sync): phased SW resync with experiment-ready config
Adds a three-phase service worker session resync strategy controlled by feature flags: foreground resync on focus, visible heartbeat, and adaptive backoff with jitter. Phase flags are wired to an experiment variant so rollout can be controlled via client config. Includes changeset.
1 parent fcff9ef commit a1445fe

17 files changed

Lines changed: 417 additions & 32 deletions

File tree

.changeset/add_shorts_support_to_fix_crash.md

Lines changed: 0 additions & 5 deletions
This file was deleted.

.changeset/fix_pmp_id_handling.md

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
default: minor
3+
---
4+
5+
Add phased service-worker session re-sync controls (foreground resync, visible heartbeat, adaptive backoff/jitter) integrated with experiment-ready config and environment-based overrides.

.github/actions/setup/action.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ runs:
3434
env:
3535
INPUTS_INSTALL_COMMAND: ${{ inputs.install-command }}
3636

37+
- name: Inject runtime config overrides
38+
if: ${{ inputs.build == 'true' }}
39+
shell: bash
40+
working-directory: ${{ github.workspace }}
41+
run: node scripts/inject-client-config.js
42+
env:
43+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ env.CLIENT_CONFIG_OVERRIDES_JSON }}
44+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ env.CLIENT_CONFIG_OVERRIDES_STRICT }}
45+
3746
- name: Build app
3847
if: ${{ inputs.build == 'true' }}
3948
shell: bash

.github/workflows/cloudflare-web-deploy.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ jobs:
4040
plan:
4141
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
4242
runs-on: ubuntu-latest
43+
environment: preview
44+
env:
45+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
46+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
4347
permissions:
4448
contents: read
4549
pull-requests: write
@@ -73,6 +77,10 @@ jobs:
7377
apply:
7478
if: (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')) || github.event_name == 'workflow_dispatch'
7579
runs-on: ubuntu-latest
80+
environment: production
81+
env:
82+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
83+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
7684
permissions:
7785
contents: read
7886
defaults:

.github/workflows/cloudflare-web-preview.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ jobs:
3232
deploy:
3333
if: github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'push'
3434
runs-on: ubuntu-latest
35+
environment: preview
3536
permissions:
3637
contents: read
3738
pull-requests: write
39+
env:
40+
CLIENT_CONFIG_OVERRIDES_JSON: ${{ vars.CLIENT_CONFIG_OVERRIDES_JSON }}
41+
CLIENT_CONFIG_OVERRIDES_STRICT: ${{ vars.CLIENT_CONFIG_OVERRIDES_STRICT || 'false' }}
3842
steps:
3943
- name: Checkout repository
4044
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

config.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,29 @@
1313
"webPushAppID": "moe.sable.app.sygnal"
1414
},
1515

16+
"experiments": {
17+
"sessionSyncStrategy": {
18+
"enabled": false,
19+
"rolloutPercentage": 0,
20+
"controlVariant": "control",
21+
"variants": ["session-sync-heartbeat", "session-sync-adaptive"]
22+
}
23+
},
24+
1625
"slidingSync": {
1726
"enabled": true
1827
},
1928

29+
"sessionSync": {
30+
"phase1ForegroundResync": false,
31+
"phase2VisibleHeartbeat": false,
32+
"phase3AdaptiveBackoffJitter": false,
33+
"foregroundDebounceMs": 1500,
34+
"heartbeatIntervalMs": 600000,
35+
"resumeHeartbeatSuppressMs": 60000,
36+
"heartbeatMaxBackoffMs": 1800000
37+
},
38+
2039
"featuredCommunities": {
2140
"openAsDefault": false,
2241
"spaces": [

knip.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"$schema": "https://unpkg.com/knip@5/schema.json",
3-
"entry": ["src/sw.ts", "scripts/normalize-imports.js"],
3+
"entry": ["src/sw.ts", "scripts/normalize-imports.js", "scripts/inject-client-config.js"],
44
"ignoreExportsUsedInFile": {
55
"interface": true,
66
"type": true

scripts/inject-client-config.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { readFile, writeFile } from 'node:fs/promises';
2+
import process from 'node:process';
3+
import { PrefixedLogger } from './utils/console-style.js';
4+
5+
const CONFIG_PATH = 'config.json';
6+
const OVERRIDES_ENV = 'CLIENT_CONFIG_OVERRIDES_JSON';
7+
const STRICT_ENV = 'CLIENT_CONFIG_OVERRIDES_STRICT';
8+
const logger = new PrefixedLogger('[config-inject]');
9+
10+
const formatError = (error) => {
11+
if (error instanceof Error) return error.stack ?? error.message;
12+
return String(error);
13+
};
14+
15+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
16+
17+
const isPlainObject = (value) =>
18+
typeof value === 'object' && value !== null && !Array.isArray(value);
19+
20+
const deepMerge = (target, source) => {
21+
if (!isPlainObject(target) || !isPlainObject(source)) return source;
22+
23+
const merged = { ...target };
24+
Object.entries(source).forEach(([key, value]) => {
25+
if (UNSAFE_KEYS.has(key)) return;
26+
const targetValue = merged[key];
27+
merged[key] =
28+
isPlainObject(targetValue) && isPlainObject(value) ? deepMerge(targetValue, value) : value;
29+
});
30+
return merged;
31+
};
32+
33+
const failOnError = process.env[STRICT_ENV] === 'true';
34+
const overridesRaw = process.env[OVERRIDES_ENV];
35+
36+
if (!overridesRaw) {
37+
logger.info(`No ${OVERRIDES_ENV} provided; leaving ${CONFIG_PATH} unchanged.`);
38+
process.exit(0);
39+
}
40+
41+
let fileConfig;
42+
let overrides;
43+
44+
try {
45+
const file = await readFile(CONFIG_PATH, 'utf8');
46+
fileConfig = JSON.parse(file);
47+
} catch (error) {
48+
logger.error(`Failed reading ${CONFIG_PATH}: ${formatError(error)}`);
49+
process.exit(1);
50+
}
51+
52+
try {
53+
overrides = JSON.parse(overridesRaw);
54+
if (!isPlainObject(overrides)) {
55+
throw new Error(`${OVERRIDES_ENV} must be a JSON object.`);
56+
}
57+
} catch (error) {
58+
const message = `[config-inject] Invalid ${OVERRIDES_ENV}; ${
59+
failOnError ? 'failing build' : 'skipping overrides'
60+
}.`;
61+
if (failOnError) {
62+
logger.error(`${message} ${formatError(error)}`);
63+
process.exit(1);
64+
}
65+
logger.info(`[warning] ${message} ${formatError(error)}`);
66+
process.exit(0);
67+
}
68+
69+
const mergedConfig = deepMerge(fileConfig, overrides);
70+
71+
await writeFile(CONFIG_PATH, `${JSON.stringify(mergedConfig, null, 2)}\n`, 'utf8');
72+
logger.info(
73+
`Applied overrides to ${CONFIG_PATH}. Top-level keys: ${Object.keys(overrides).join(', ')}`
74+
);

src/app/components/url-preview/ClientPreview.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -167,14 +167,10 @@ function parseYoutubeLink(url: string): YoutubeLink | null {
167167
const split = path.split('?');
168168
[videoId] = split;
169169
params = split[1]?.split('&');
170-
} else if (url.includes('/shorts/')) {
171-
const split = path.split('/shorts/');
172-
[videoId] = split;
173-
params = split[1]?.split('shorts');
174-
} else if (url.includes('youtube.com')) {
170+
} else {
175171
params = path.split('?')[1].split('&');
176172
videoId = params.find((s) => s.startsWith('v='), params)?.split('v=')[1];
177-
} else return null;
173+
}
178174

179175
if (!videoId) return null;
180176

0 commit comments

Comments
 (0)