-
Notifications
You must be signed in to change notification settings - Fork 3
feat: add SDK compatibility layer for bridging new and old SDKs #499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
Introduces @epcc-sdk/compatibility-layer package that enables the new TypeScript SDKs to share auth, retry, and throttle logic with the existing @moltin/sdk without requiring changes to the old SDK. Key features: - SharedAuthState for token management with promise deduplication - Retry logic with exponential backoff (401/429 handling) - Request throttling via throttled-queue - Legacy storage bridge for sharing tokens via localStorage events - Client registry for multi-client scenarios (Commerce Manager pattern) - Full SSR support with memory/cookie/localStorage adapters Includes 84 unit tests and comprehensive documentation.
- Add MSW-based integration tests for token sharing, bridged shopper, and multi-client scenarios - Replace api.moltin.com with api.elasticpath.com across all files - Replace @moltin/sdk with @elasticpath/js-sdk in documentation - Add vitest.integration.config.ts for separate integration test runs - 25 integration tests covering real SDK usage patterns
|
The latest updates on your projects. Learn more about Vercel for GitHub.
4 Skipped Deployments
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a new @epcc-sdk/compatibility-layer package that enables the new TypeScript SDKs to bridge with the legacy JavaScript SDK's authentication, retry, and throttle logic. The implementation allows seamless token sharing between old and new SDKs without requiring changes to the old SDK.
Changes:
- New compatibility layer package with shared auth state, retry logic, and request throttling
- Storage adapters for localStorage, cookies, and memory (SSR support)
- Client registry for managing multiple client instances with different auth contexts
- Comprehensive test suite with 84 unit tests and 25 MSW-based integration tests
Reviewed changes
Copilot reviewed 26 out of 27 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Package configuration with dependencies and peer dependencies |
| tsconfig.json, tsconfig.node.json | TypeScript configuration for both runtime and build tools |
| tsup.config.ts | Build configuration for ESM and CJS outputs |
| vitest.config.ts, vitest.integration.config.ts | Test configurations for unit and integration tests |
| src/types.ts | Core type definitions and interfaces |
| src/index.ts | Public API exports |
| src/fetch/throttle.ts | Request throttling implementation using throttled-queue |
| src/fetch/fetch-with-retry.ts | Retry logic with exponential backoff |
| src/client/create-bridged-client.ts | Client factory for bridging SDKs |
| src/client/client-registry.ts | Multi-client management registry |
| src/auth/shared-auth-state.ts | Shared authentication state with promise deduplication |
| src/auth/legacy-storage-bridge.ts | Storage adapters for different backends |
| src/integration/*.test.ts | Integration tests with MSW mocking |
| README.md | Comprehensive API documentation and examples |
Files not reviewed (1)
- packages/sdks/compatibility-layer/pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
packages/sdks/compatibility-layer/src/auth/shared-auth-state.ts
Outdated
Show resolved
Hide resolved
packages/sdks/compatibility-layer/src/__integration__/bridged-shopper.test.ts
Outdated
Show resolved
Hide resolved
packages/sdks/compatibility-layer/src/__integration__/bridged-shopper.test.ts
Outdated
Show resolved
Hide resolved
packages/sdks/compatibility-layer/src/__integration__/bridged-shopper.test.ts
Outdated
Show resolved
Hide resolved
packages/sdks/compatibility-layer/src/__integration__/token-sharing.test.ts
Outdated
Show resolved
Hide resolved
- Test concurrent getValidAccessToken() from multiple clients sharing same storage - Test that second client reuses token refreshed by first client - Verifies at most 2 auth requests when both clients race to refresh
- Remove unnecessary JWT decoding from shared-auth-state (EP tokens have expires field) - Fix immutable Request headers by using Request constructor to clone - Add JSDoc warning about global throttle queue behavior - Use globalThis.fetch explicitly in client-registry to avoid circular deps - Remove unused variables and imports in test files
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated 10 comments.
Files not reviewed (1)
- packages/sdks/compatibility-layer/pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| outDir: "dist", | ||
| outExtension(ctx) { | ||
| return { | ||
| dts: ".d.ts", |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The export format in package.json specifies .d.cts for the CommonJS types declaration, but the tsup configuration's outExtension function returns .d.ts for all formats. This mismatch means the CommonJS type declarations file won't exist at the expected path, causing TypeScript to fail when importing this package via CommonJS.
The outExtension function should return .d.cts for CommonJS type declarations to match the package.json exports.
| dts: ".d.ts", | |
| dts: ctx.format === "cjs" ? ".d.cts" : ".d.ts", |
| describe("Throttling", () => { | ||
| it("should throttle rapid requests when enabled", async () => { | ||
| const storage = createLegacyStorageBridge({ backend: "memory" }) | ||
|
|
||
| const { auth } = createBridgedClient(shopperClient, { | ||
| baseUrl: "https://api.elasticpath.com", | ||
| clientId: "test-client-id", | ||
| storage, | ||
| throttle: { enabled: true, limit: 2, interval: 100 }, | ||
| }) | ||
|
|
||
| const token = await auth.getValidAccessToken() | ||
|
|
||
| // Make multiple rapid requests | ||
| await Promise.all([ | ||
| fetch("https://api.elasticpath.com/catalog/products", { | ||
| headers: { Authorization: `Bearer ${token}` }, | ||
| }), | ||
| fetch("https://api.elasticpath.com/catalog/products", { | ||
| headers: { Authorization: `Bearer ${token}` }, | ||
| }), | ||
| fetch("https://api.elasticpath.com/catalog/products", { | ||
| headers: { Authorization: `Bearer ${token}` }, | ||
| }), | ||
| fetch("https://api.elasticpath.com/catalog/products", { | ||
| headers: { Authorization: `Bearer ${token}` }, | ||
| }), | ||
| ]) | ||
|
|
||
| // All requests should complete | ||
| const { productRequestCount } = getRequestCounts() | ||
| expect(productRequestCount).toBe(4) | ||
|
|
||
| // Should have taken some time due to throttling | ||
| // With limit=2 and interval=100ms, 4 requests should take at least 100ms | ||
| // But we don't enforce exact timing in tests due to CI variability | ||
| }) |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test creates a bridged client with throttle configuration but then uses plain fetch instead of the bridged fetch configured on the client. This means the test isn't actually exercising the throttling logic that was configured. The test should use the bridged fetch that includes throttling to properly verify this behavior.
| } from "esbuild-fix-imports-plugin" | ||
|
|
||
| export default defineConfig({ | ||
| entry: ["src/**/*.ts", "!src/**/*.test.ts"], |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The entry pattern "src/**/*.ts" with exclusion "!src/**/*.test.ts" doesn't exclude test files in subdirectories correctly. The exclusion pattern should be more specific. While the exclude array in the same config has the correct pattern, having both inclusion and exclusion in the entry could lead to unexpected behavior. Consider simplifying to just exclude test files via the exclude array or using a more explicit entry pattern.
| if (!skipAuth && authState) { | ||
| try { | ||
| const token = await authState.getValidAccessToken() | ||
| if (token && !request.headers.has("Authorization")) { | ||
| // Request headers are immutable, so we need to create a new request | ||
| const newHeaders = new Headers(request.headers) | ||
| newHeaders.set("Authorization", `Bearer ${token}`) | ||
| request = new Request(request, { headers: newHeaders }) | ||
| } |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function creates a new Headers object and a new Request object even when the Authorization header already exists. This creates unnecessary Request objects. Consider checking if the header already exists before creating a new Request, or only create new objects when actually needed.
| } catch { | ||
| // Auth refresh failed, return original 401 response |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The catch block silently swallows the auth refresh error. While returning the original 401 response is reasonable, it would be helpful for debugging to log the refresh failure or provide some indication of why the reauth failed. Consider adding optional error logging.
| } catch { | |
| // Auth refresh failed, return original 401 response | |
| } catch (error) { | |
| // Auth refresh failed, log error for debugging and return original 401 response | |
| console.error("Auth token refresh failed during fetchWithRetry:", error) |
| delete: () => { | ||
| customStorage["token"] = null | ||
| }, |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The delete method defined in the custom storage adapter is never used by the StorageAdapter interface. The interface only expects a set method (which handles both setting and clearing by passing undefined). This extra method will be ignored and could cause confusion. Either remove the delete method or update the adapter to match the interface's API.
| delete: () => { | |
| customStorage["token"] = null | |
| }, |
| describe("Retry on 401", () => { | ||
| it("should retry with fresh token on 401 response", async () => { | ||
| const storage = createLegacyStorageBridge({ backend: "memory" }) | ||
|
|
||
| // Pre-populate with a token | ||
| storage.set( | ||
| JSON.stringify({ | ||
| access_token: "expired-token", | ||
| expires: Math.floor(Date.now() / 1000) + 3600, // Not actually expired | ||
| }) | ||
| ) | ||
|
|
||
| // Set up MSW to return 401 once, then succeed | ||
| setReturn401(true, 1) | ||
|
|
||
| const { auth } = createBridgedClient(shopperClient, { | ||
| baseUrl: "https://api.elasticpath.com", | ||
| clientId: "test-client-id", | ||
| storage, | ||
| retry: { maxAttempts: 3, reauth: true }, | ||
| }) | ||
|
|
||
| // Use the bridged fetch to make a request | ||
| const token = await auth.getValidAccessToken() | ||
| const response = await globalThis.fetch( | ||
| "https://api.elasticpath.com/catalog/products", | ||
| { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| } | ||
| ) | ||
|
|
||
| // Note: The retry happens at fetch level, not at auth level | ||
| // This test verifies the auth system works, the retry-on-401 is tested | ||
| // in the fetch-with-retry unit tests | ||
| expect(response.status).toBe(401) // First request gets 401 |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test uses globalThis.fetch directly instead of the bridged fetch function that was configured on the client. This means the test isn't actually testing the retry-on-401 functionality of the compatibility layer. To properly test the retry logic, the test should either use the bridged fetch from createBridgedFetch or configure the client's fetch and make requests through it.
| describe("Rate limiting handling", () => { | ||
| it("should handle 429 responses gracefully", async () => { | ||
| const storage = createLegacyStorageBridge({ backend: "memory" }) | ||
|
|
||
| setReturn429(true) | ||
|
|
||
| const { auth } = createBridgedClient(shopperClient, { | ||
| baseUrl: "https://api.elasticpath.com", | ||
| clientId: "test-client-id", | ||
| storage, | ||
| }) | ||
|
|
||
| const token = await auth.getValidAccessToken() | ||
|
|
||
| // First request should get 429, but we're just testing auth works | ||
| const response = await globalThis.fetch( | ||
| "https://api.elasticpath.com/catalog/products", | ||
| { | ||
| headers: { | ||
| Authorization: `Bearer ${token}`, | ||
| }, | ||
| } | ||
| ) | ||
|
|
||
| // MSW handler returns 429 once then succeeds | ||
| expect(response.status).toBe(429) | ||
| }) |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The test manually constructs the Authorization header and uses globalThis.fetch directly, which bypasses the bridged fetch configured with retry and throttle logic. This test doesn't actually verify the retry-on-429 behavior of the compatibility layer. The test should use the bridged fetch that includes the retry logic.
| } catch { | ||
| // If token fetch fails, continue without auth | ||
| // The request will likely fail with 401 which triggers reauth |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The catch block silently swallows the error without any logging or indication of what went wrong. While the comment explains the intent, in production debugging scenarios it would be valuable to at least log the error (perhaps at debug level) so developers can understand why token fetch failed. Consider adding optional error logging or a callback for error handling.
| } catch { | |
| // If token fetch fails, continue without auth | |
| // The request will likely fail with 401 which triggers reauth | |
| } catch (error) { | |
| // If token fetch fails, continue without auth | |
| // The request will likely fail with 401 which triggers reauth | |
| console.debug( | |
| "fetchWithRetry: Failed to obtain access token for request", | |
| request.url, | |
| error | |
| ) |
| try { | ||
| return decodeURIComponent(raw.split("=").slice(1).join("=")) | ||
| } catch { | ||
| return raw.split("=").slice(1).join("=") |
Copilot
AI
Jan 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The cookie reading logic uses find and then manually splits the value, but doesn't handle edge cases like cookies with multiple = characters in the value (which are valid). While slice(1).join("=") is used, it's applied after decodeURIComponent fails. The logic should handle the split more carefully to ensure all parts of the value after the first = are preserved before attempting decode.
| try { | |
| return decodeURIComponent(raw.split("=").slice(1).join("=")) | |
| } catch { | |
| return raw.split("=").slice(1).join("=") | |
| const firstEquals = raw.indexOf("=") | |
| if (firstEquals === -1) return undefined | |
| const valuePart = raw.substring(firstEquals + 1) | |
| try { | |
| return decodeURIComponent(valuePart) | |
| } catch { | |
| return valuePart |
Summary
@epcc-sdk/compatibility-layerpackage that enables new TypeScript SDKs to share auth, retry, andthrottle logic with the old JS SDK
Features
throttled-queueTest plan
Files
New package at
packages/sdks/compatibility-layer/with:src/auth/- SharedAuthState and legacy storage bridgesrc/fetch/- Retry and throttle logicsrc/client/- Bridged client factory and registrysrc/__integration__/- MSW-based integration tests