diff --git a/packages/core/package.json b/packages/core/package.json index e9fdd028b77..88082e65ab3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -67,6 +67,13 @@ "node": "./dist-cjs/submodules/event-streams/index.js", "import": "./dist-es/submodules/event-streams/index.js", "require": "./dist-cjs/submodules/event-streams/index.js" + }, + "./uuid": { + "types": "./dist-types/submodules/uuid/index.d.ts", + "module": "./dist-es/submodules/uuid/index.js", + "node": "./dist-cjs/submodules/uuid/index.js", + "import": "./dist-es/submodules/uuid/index.js", + "require": "./dist-cjs/submodules/uuid/index.js" } }, "author": { @@ -110,6 +117,8 @@ "./schema.js", "./serde.d.ts", "./serde.js", + "./uuid.d.ts", + "./uuid.js", "dist-*/**" ], "homepage": "https://github.com/smithy-lang/smithy-typescript/tree/main/packages/core", @@ -127,6 +136,10 @@ "rimraf": "3.0.2", "typedoc": "0.23.23" }, + "browser": { + "./dist-es/submodules/uuid/randomUUID": "./dist-es/submodules/uuid/randomUUID.browser" + }, + "react-native": {}, "typedoc": { "entryPoint": "src/index.ts" }, diff --git a/packages/core/src/submodules/uuid/index.ts b/packages/core/src/submodules/uuid/index.ts new file mode 100644 index 00000000000..d988279e5df --- /dev/null +++ b/packages/core/src/submodules/uuid/index.ts @@ -0,0 +1 @@ +export * from "./v4"; \ No newline at end of file diff --git a/packages/core/src/submodules/uuid/randomUUID.browser.ts b/packages/core/src/submodules/uuid/randomUUID.browser.ts new file mode 100644 index 00000000000..c2760b418cd --- /dev/null +++ b/packages/core/src/submodules/uuid/randomUUID.browser.ts @@ -0,0 +1 @@ +export const randomUUID = typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID.bind(crypto); diff --git a/packages/core/src/submodules/uuid/randomUUID.native.ts b/packages/core/src/submodules/uuid/randomUUID.native.ts new file mode 100644 index 00000000000..01da2fdda2d --- /dev/null +++ b/packages/core/src/submodules/uuid/randomUUID.native.ts @@ -0,0 +1,2 @@ +// If user has provided their polyfill, like "react-native-random-uuid" +export const randomUUID = typeof crypto !== "undefined" && crypto.randomUUID && crypto.randomUUID.bind(crypto); diff --git a/packages/core/src/submodules/uuid/randomUUID.ts b/packages/core/src/submodules/uuid/randomUUID.ts new file mode 100644 index 00000000000..98cb6455670 --- /dev/null +++ b/packages/core/src/submodules/uuid/randomUUID.ts @@ -0,0 +1,4 @@ +// ToDo: Merge Node.js and browser implementations after dropping support for Node.js 22.x +import crypto from "crypto"; + +export const randomUUID = crypto.randomUUID.bind(crypto); diff --git a/packages/core/src/submodules/uuid/v4.spec.ts b/packages/core/src/submodules/uuid/v4.spec.ts new file mode 100644 index 00000000000..50e73b63687 --- /dev/null +++ b/packages/core/src/submodules/uuid/v4.spec.ts @@ -0,0 +1,46 @@ +import { getRandomValues } from "crypto"; +import { afterEach, beforeEach, describe, expect, test as it, vi } from "vitest"; + +describe("randomUUID", () => { + afterEach(() => { + vi.resetModules(); + }); + + it("should call native randomUUID when available", async () => { + const mockUUID = "mocked-uuid"; + const nativeRandomUUID = vi.fn(() => mockUUID); + vi.doMock("./randomUUID", () => ({ randomUUID: nativeRandomUUID })); + + const { v4 } = await import("./v4"); + const uuid = v4(); + + expect(nativeRandomUUID).toHaveBeenCalled(); + expect(uuid).toBe(mockUUID); + }); + + describe("when native randomUUID is not available", () => { + let v4: any; + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + + beforeEach(async () => { + vi.doMock("./randomUUID", () => ({ randomUUID: undefined })); + v4 = (await import("./v4")).v4; + + // Simulate crypto.getRandomValues in test, as it's expected to be available + global.crypto = { + getRandomValues: getRandomValues, + } as any; + }); + + it("each generation is unique and matches regex", () => { + const uuids = new Set(); + const iterations = 10_000; + for (let i = 0; i < iterations; i++) { + const uuid = v4(); + expect(uuid).toMatch(UUID_REGEX); + uuids.add(uuid); + } + expect(uuids.size).toBe(iterations); + }); + }); +}); \ No newline at end of file diff --git a/packages/core/src/submodules/uuid/v4.ts b/packages/core/src/submodules/uuid/v4.ts new file mode 100644 index 00000000000..2d3336de6ac --- /dev/null +++ b/packages/core/src/submodules/uuid/v4.ts @@ -0,0 +1,19 @@ +import { randomUUID } from "./randomUUID"; + +export const v4 = () => { + if (randomUUID) { + return randomUUID(); + } + + const rnds = new Uint8Array(16); + crypto.getRandomValues(rnds); + + // Set version (4) and variant (RFC4122) + rnds[6] = (rnds[6] & 0x0f) | 0x40; // version 4 + rnds[8] = (rnds[8] & 0x3f) | 0x80; // variant + + return Array.from(rnds.slice(0, 16)) + .map((b) => b.toString(16).padStart(2, "0")) + .join("") + .replace(/(.{8})(.{4})(.{4})(.{4})(.{12})/, "$1-$2-$3-$4-$5"); +}; \ No newline at end of file diff --git a/packages/core/tsconfig.cjs.json b/packages/core/tsconfig.cjs.json index e9e9ab91f48..f3fb5789f22 100644 --- a/packages/core/tsconfig.cjs.json +++ b/packages/core/tsconfig.cjs.json @@ -8,7 +8,8 @@ "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], "@smithy/core/serde": ["./src/submodules/serde/index.ts"], "@smithy/core/schema": ["./src/submodules/schema/index.ts"], - "@smithy/core/event-streams": ["./src/submodules/event-streams/index.ts"] + "@smithy/core/event-streams": ["./src/submodules/event-streams/index.ts"], + "@smithy/core/uuid": ["./src/submodules/uuid/index.ts"] } }, "extends": "../../tsconfig.cjs.json", diff --git a/packages/core/tsconfig.es.json b/packages/core/tsconfig.es.json index 8cab10dd9fb..e197d5d5137 100644 --- a/packages/core/tsconfig.es.json +++ b/packages/core/tsconfig.es.json @@ -9,7 +9,8 @@ "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], "@smithy/core/serde": ["./src/submodules/serde/index.ts"], "@smithy/core/schema": ["./src/submodules/schema/index.ts"], - "@smithy/core/event-streams": ["./src/submodules/event-streams/index.ts"] + "@smithy/core/event-streams": ["./src/submodules/event-streams/index.ts"], + "@smithy/core/uuid": ["./src/submodules/uuid/index.ts"] } }, "extends": "../../tsconfig.es.json", diff --git a/packages/core/tsconfig.types.json b/packages/core/tsconfig.types.json index 14f10909722..d1e9ee72252 100644 --- a/packages/core/tsconfig.types.json +++ b/packages/core/tsconfig.types.json @@ -8,7 +8,8 @@ "@smithy/core/protocols": ["./src/submodules/protocols/index.ts"], "@smithy/core/serde": ["./src/submodules/serde/index.ts"], "@smithy/core/schema": ["./src/submodules/schema/index.ts"], - "@smithy/core/event-streams": ["./src/submodules/event-streams/index.ts"] + "@smithy/core/event-streams": ["./src/submodules/event-streams/index.ts"], + "@smithy/core/uuid": ["./src/submodules/uuid/index.ts"] } }, "extends": "../../tsconfig.types.json", diff --git a/packages/core/uuid.d.ts b/packages/core/uuid.d.ts new file mode 100644 index 00000000000..b25d7756013 --- /dev/null +++ b/packages/core/uuid.d.ts @@ -0,0 +1,7 @@ +/** + * Do not edit: + * This is a compatibility redirect for contexts that do not understand package.json exports field. + */ +declare module "@smithy/core/uuid" { + export * from "@smithy/core/dist-types/submodules/uuid/index.d"; +} diff --git a/packages/core/uuid.js b/packages/core/uuid.js new file mode 100644 index 00000000000..2835a5092d5 --- /dev/null +++ b/packages/core/uuid.js @@ -0,0 +1,6 @@ + +/** + * Do not edit: + * This is a compatibility redirect for contexts that do not understand package.json exports field. + */ +module.exports = require("./dist-cjs/submodules/uuid/index.js");