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
4 changes: 4 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@
"no-throw-literal": "off",
"no-warning-comments": "off",
"unicorn/consistent-destructuring": "off",
"unicorn/no-array-for-each": "off",
"unicorn/no-array-reduce": "off",
"unicorn/prefer-ternary": "off",
"unicorn/switch-case-braces": "off",
"padding-line-between-statements": "off",
"prefer-destructuring": "off"
},
"overrides": [
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
"acorn": "^8.10.0",
"acorn-walk": "^8.2.0",
"chalk": "^5.4.1",
"common-tags": "^1.8.2",
"fuzzy": "^0.1.3",
"inquirer-autocomplete-standalone": "^0.8.1",
"lodash.camelcase": "^4.3.0",
"mustache": "^4.2.0",
"zod": "^3.22.4"
},
Expand All @@ -21,6 +23,8 @@
"@oclif/prettier-config": "^0.2.1",
"@oclif/test": "^3",
"@types/chai": "^4",
"@types/common-tags": "^1.8.4",
"@types/lodash.camelcase": "^4.3.9",
"@types/mocha": "^10",
"@types/mustache": "^4.2.5",
"@types/node": "^18",
Expand Down
2 changes: 1 addition & 1 deletion src/codegen/code-generators/base-generator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type ConfigFile} from '../types.js'

interface BaseGeneratorArgs {
export interface BaseGeneratorArgs {
configFile: ConfigFile
log: (category: string | unknown, message?: unknown) => void
}
Expand Down
44 changes: 44 additions & 0 deletions src/codegen/code-generators/base-typescript-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {z} from 'zod'

import {ZodToTypescriptMapper} from '../language-mappers/zod-to-typescript-mapper.js'
import {SchemaExtractor} from '../schema-extractor.js'
import {BaseGenerator, BaseGeneratorArgs} from './base-generator.js'

export abstract class BaseTypescriptGenerator extends BaseGenerator {
protected MUSTACHE_IMPORT = "import Mustache from 'mustache'"
private schemaExtractor: SchemaExtractor

constructor({configFile, log}: BaseGeneratorArgs) {
super({configFile, log})
this.schemaExtractor = new SchemaExtractor(log)
}

protected configurations() {
return this.configFile.configs
.filter((config) => config.configType === 'FEATURE_FLAG' || config.configType === 'CONFIG')
.filter((config) => config.rows.length > 0)
.sort((a, b) => a.key.localeCompare(b.key))
.map((config) => {
const schema = this.schemaExtractor.execute({
config,
configFile: this.configFile,
durationTypeMap: this.durationTypeMap,
})

return {
configType: config.configType,
hasFunction: schema && new ZodToTypescriptMapper().resolveType(schema).includes('=>'),
key: config.key,
schema,
sendToClientSdk: config.sendToClientSdk ?? false,
}
})
}

protected durationTypeMap(): z.ZodTypeAny {
return z.number()
}

abstract get filename(): string
abstract generate(): string
}
105 changes: 105 additions & 0 deletions src/codegen/code-generators/node-typescript-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {stripIndent} from 'common-tags'
import camelCase from 'lodash.camelcase'

import {ZodToTypescriptMapper, type ZodToTypescriptMapperTarget} from '../language-mappers/zod-to-typescript-mapper.js'
import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js'
import {BaseTypescriptGenerator} from './base-typescript-generator.js'

export class NodeTypeScriptGenerator extends BaseTypescriptGenerator {
get filename(): string {
return 'prefab-server.ts'
}

generate(): string {
return stripIndent`
/* eslint-disable */
// AUTOGENERATED by prefab-cli's 'gen' command
import {Prefab, Contexts} from '@prefab-cloud/prefab-cloud-node'
${this.additionalDependencies().join('\n') || '// No additional dependencies required'}

type ContextObj = Record<string, Record<string, unknown>>

declare namespace PrefabTypeGeneration {
export type NodeServerConfigurationRaw = {
${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'}
}

export type NodeServerConfigurationAccessor = {
${this.generateSchemaTypes().join('\n ') || '// No types generated'}
}
}

export class PrefabTypesafeNode {
constructor(private prefab: Prefab) { }

get<K extends keyof PrefabTypeGeneration.NodeServerConfigurationRaw>(key: K, contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationRaw[K] {
return this.prefab.get(key, contexts) as PrefabTypeGeneration.NodeServerConfigurationRaw[K]
}

${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'}
}
`
}

private additionalDependencies(): string[] {
const dependencies: string[] = []
const hasFunctions = this.configurations().some((c) => c.hasFunction)

if (hasFunctions) {
dependencies.push(this.MUSTACHE_IMPORT)
}

return dependencies
}

private generateAccessorMethods(): string[] {
const uniqueMethods: Record<string, string> = {}
const schemaTypes = this.configurations().map((config) => {
let methodName = camelCase(config.key)

// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
if (/^\d/.test(methodName)) {
methodName = `_${methodName}`
}

console.log(config.key, methodName)

if (uniqueMethods[methodName]) {
throw new Error(
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
)
}

uniqueMethods[methodName] = config.key

if (config.hasFunction) {
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)

return stripIndent`
${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] {
const raw = this.get('${config.key}', contexts)
return ${returnValue}
}
`
}

return stripIndent`
${methodName}(contexts?: Contexts | ContextObj): PrefabTypeGeneration.NodeServerConfigurationAccessor['${config.key}'] {
return this.get('${config.key}', contexts)
}
`
})

return schemaTypes
}

private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] {
const schemaTypes = this.configurations().flatMap((config) => {
const mapper = new ZodToTypescriptMapper({fieldName: config.key, target})

return mapper.renderField(config.schema)
})

return schemaTypes
}
}
123 changes: 123 additions & 0 deletions src/codegen/code-generators/react-typescript-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import {stripIndent} from 'common-tags'
import camelCase from 'lodash.camelcase'
import {z} from 'zod'

import {ZodToTypescriptMapper, type ZodToTypescriptMapperTarget} from '../language-mappers/zod-to-typescript-mapper.js'
import {ZodToTypescriptReturnValueMapper} from '../language-mappers/zod-to-typescript-return-value-mapper.js'
import {BaseTypescriptGenerator} from './base-typescript-generator.js'

export class ReactTypeScriptGenerator extends BaseTypescriptGenerator {
get filename(): string {
return 'prefab-client.ts'
}

protected durationTypeMap(): z.ZodTypeAny {
return z.object({ms: z.number(), seconds: z.number()})
}

generate(): string {
return stripIndent`
/* eslint-disable */
// AUTOGENERATED by prefab-cli's 'gen' command
import { Prefab } from "@prefab-cloud/prefab-cloud-js"
import { createPrefabHook } from "@prefab-cloud/prefab-cloud-react"
${this.additionalDependencies().join('\n') || '// No additional dependencies required'}

declare namespace PrefabTypeGeneration {
export type ReactHookConfigurationRaw = {
${this.generateSchemaTypes('raw').join('\n ') || '// No types generated'}
}

export type ReactHookConfigurationAccessor = {
${this.generateSchemaTypes().join('\n ') || '// No types generated'}
}
}

export class PrefabTypesafeReact {
constructor(private prefab: Prefab) { }

get<K extends keyof PrefabTypeGeneration.ReactHookConfigurationRaw>(key: K): PrefabTypeGeneration.ReactHookConfigurationRaw[K] {
return this.prefab.get(key) as PrefabTypeGeneration.ReactHookConfigurationRaw[K]
}

${this.generateAccessorMethods().join('\n\n ') || '// No methods generated'}
}

export const usePrefab = createPrefabHook(PrefabTypesafeReact)
`
}

private additionalDependencies(): string[] {
const dependencies: string[] = []
const hasFunctions = this.filteredConfigurations().some((c) => c.hasFunction)

if (hasFunctions) {
dependencies.push(this.MUSTACHE_IMPORT)
}

return dependencies
}

private filteredConfigurations() {
return this.configurations().filter(
(config) => config.configType === 'FEATURE_FLAG' || config.sendToClientSdk === true,
)
}

private generateAccessorMethods(): string[] {
const uniqueMethods: Record<string, string> = {}
const schemaTypes = this.filteredConfigurations().map((config) => {
let methodName = camelCase(config.key)

// If the method name starts with a digit, prefix it with an underscore to ensure method name is valid
if (/^\d/.test(methodName)) {
methodName = `_${methodName}`
}

if (uniqueMethods[methodName]) {
throw new Error(
`Method '${methodName}' is already registered. Prefab key ${config.key} conflicts with '${uniqueMethods[methodName]}'!`,
)
}

uniqueMethods[methodName] = config.key

if (config.configType === 'FEATURE_FLAG') {
return stripIndent`
get ${methodName}(): boolean {
return this.prefab.isEnabled('${config.key}')
}
`
}

if (config.hasFunction) {
const returnValue = new ZodToTypescriptReturnValueMapper().resolveType(config.schema)

return stripIndent`
${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
const raw = this.get('${config.key}')
return ${returnValue}
}
`
}

return stripIndent`
get ${methodName}(): PrefabTypeGeneration.ReactHookConfigurationAccessor['${config.key}'] {
return this.get('${config.key}')
}
`
})

return schemaTypes
}

private generateSchemaTypes(target: ZodToTypescriptMapperTarget = 'accessor'): string[] {
const schemaTypes = this.filteredConfigurations().map((config) => {
const mapper = new ZodToTypescriptMapper({fieldName: config.key, target})

return mapper.renderField(config.schema)
})

return schemaTypes
}
}
71 changes: 71 additions & 0 deletions src/codegen/language-mappers/json-to-zod-mapper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {z} from 'zod'

export class JsonToZodMapper {
resolve(data: unknown): z.ZodTypeAny {
if (Array.isArray(data)) {
if (data.length > 0) {
// Check if all elements in the array have the same type
const firstItem = data[0]

const isHomogeneous = data.every((item) => {
const itemsMatch = typeof item === typeof firstItem

// Special handling for objects and arrays
if (typeof firstItem === 'object') {
if (Array.isArray(item)) {
return Array.isArray(firstItem)
}

return !Array.isArray(firstItem)
}

return itemsMatch
})

// For homogeneous arrays, use the first element's type
if (isHomogeneous) {
return z.array(this.resolve(data[0]))
}

// Explicitly do not handle mixed-type arrays
// They could be tuples or heterogeneous arrays
// Instead, we return an array of unknowns
}

return z.array(z.unknown())
}

if (typeof data === 'object' && data !== null) {
const shape: Record<string, z.ZodTypeAny> = {}
const dataRecord = data as Record<string, unknown>
for (const key in dataRecord) {
if (Object.hasOwn(dataRecord, key)) {
shape[key] = this.resolve(dataRecord[key])
}
}

return z.object(shape)
}

if (typeof data === 'string') {
return z.string()
}

if (typeof data === 'number') {
return z.number()
}

if (typeof data === 'boolean') {
return z.boolean()
}

if (data === null) {
return z.null()
}

console.warn(`Unknown json type:`, data)

// If the type is not recognized, default to 'any'
return z.any()
}
}
Loading
Loading