polen 0.11.0-next.27 → 0.11.0-next.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,15 +2,11 @@
2
2
  * Worker that generates static pages by fetching from servers.
3
3
  * This is executed in a worker thread using Effect Worker API.
4
4
  */
5
- import { debugPolen } from '#singletons/debug'
6
- import { Path, WorkerRunner } from '@effect/platform'
5
+ import { FileSystem, Path, WorkerRunner } from '@effect/platform'
7
6
  import { NodeContext, NodeRuntime, NodeWorkerRunner } from '@effect/platform-node'
8
- import { Data, Effect, Layer } from 'effect'
9
- import { promises as fs } from 'node:fs'
7
+ import { Array, Data, Duration, Effect, Either, Layer } from 'effect'
10
8
  import { type GenerateResult, PageMessage } from './worker-messages.js'
11
9
 
12
- const debug = debugPolen.sub(`api:ssg:page-generator`)
13
-
14
10
  // ============================================================================
15
11
  // Error Types
16
12
  // ============================================================================
@@ -31,85 +27,122 @@ class RouteProcessingError extends Data.Error<{
31
27
  // Generate Pages Handler
32
28
  // ============================================================================
33
29
 
30
+ // Fetch HTML from server using Effect patterns
31
+ const fetchPage = (url: string) =>
32
+ Effect.gen(function*() {
33
+ const response = yield* Effect.tryPromise({
34
+ try: () => fetch(url),
35
+ catch: (error) => new Error(`Network error: ${error}`),
36
+ })
37
+
38
+ if (!response.ok) {
39
+ return yield* Effect.fail(
40
+ new Error(`HTTP ${response.status}: ${response.statusText}`),
41
+ )
42
+ }
43
+
44
+ return yield* Effect.tryPromise({
45
+ try: () => response.text(),
46
+ catch: (error) => new Error(`Failed to read response: ${error}`),
47
+ })
48
+ })
49
+
50
+ // Write HTML to file system
51
+ const writeHtmlFile = (outputPath: string, html: string) =>
52
+ Effect.gen(function*() {
53
+ const fs = yield* FileSystem.FileSystem
54
+ const path = yield* Path.Path
55
+
56
+ const dir = path.dirname(outputPath)
57
+ yield* fs.makeDirectory(dir, { recursive: true })
58
+ yield* fs.writeFileString(outputPath, html)
59
+ })
60
+
61
+ // Process a single route
62
+ const processRoute = (
63
+ route: string,
64
+ serverPort: number,
65
+ outputDir: string,
66
+ basePath?: string,
67
+ ) =>
68
+ Effect.gen(function*() {
69
+ const path = yield* Path.Path
70
+
71
+ // Fetch the page from the server
72
+ // Construct URL with base path if provided
73
+ const basePathNormalized = basePath ? basePath.replace(/\/$/, '') : ''
74
+ const url = `http://localhost:${serverPort}${basePathNormalized}${route}`
75
+ const html = yield* fetchPage(url).pipe(
76
+ Effect.mapError(error => new RouteProcessingError({ route, cause: error })),
77
+ )
78
+
79
+ // Determine output file path
80
+ const outputPath = path.join(
81
+ outputDir,
82
+ route === '/' ? 'index.html' : `${route.slice(1)}/index.html`,
83
+ )
84
+
85
+ // Write the HTML file
86
+ yield* writeHtmlFile(outputPath, html).pipe(
87
+ Effect.mapError(error => new RouteProcessingError({ route, cause: error })),
88
+ )
89
+
90
+ return route
91
+ })
92
+
34
93
  const handlers = {
35
94
  GeneratePages: (
36
- { routes, serverPort, outputDir }: { routes: readonly string[]; serverPort: number; outputDir: string },
95
+ { routes, serverPort, outputDir, basePath }: { routes: readonly string[]; serverPort: number; outputDir: string; basePath?: string | undefined },
37
96
  ) =>
38
97
  Effect.gen(function*() {
39
- const path = yield* Path.Path
40
- const startTime = Date.now()
41
- let processedCount = 0
42
-
43
- debug(`Starting batch generation`, {
44
- totalRoutes: routes.length,
45
- serverPort,
46
- })
47
-
48
- // Process each route by fetching from the server
49
- for (const route of routes) {
50
- yield* Effect.tryPromise({
51
- try: async () => {
52
- // Fetch the page from the server
53
- const url = `http://localhost:${serverPort}${route}`
54
- const response = await fetch(url)
55
-
56
- if (!response.ok) {
57
- throw new Error(`Failed to fetch ${route}: ${response.status} ${response.statusText}`)
58
- }
59
-
60
- const html = await response.text()
61
-
62
- // Determine output file path
63
- const outputPath = path.join(
64
- outputDir,
65
- route === '/' ? 'index.html' : `${route.slice(1)}/index.html`,
66
- )
67
-
68
- // Ensure directory exists
69
- const dir = path.dirname(outputPath)
70
- await fs.mkdir(dir, { recursive: true })
71
-
72
- // Write the HTML file
73
- await fs.writeFile(outputPath, html, 'utf-8')
74
-
75
- processedCount++
76
-
77
- // Log progress every 5 routes or on last route
78
- if (processedCount % 5 === 0 || processedCount === routes.length) {
79
- debug(`Progress`, {
80
- processedCount,
81
- totalRoutes: routes.length,
82
- serverPort,
83
- })
84
- }
85
- },
86
- catch: (error) => {
87
- debug(`Failed to process route`, { route, error })
88
- return new RouteProcessingError({ route, cause: error })
89
- },
90
- })
91
- }
98
+ yield* Effect.logDebug(`Starting batch generation`).pipe(
99
+ Effect.annotateLogs({
100
+ totalRoutes: routes.length,
101
+ serverPort,
102
+ basePath: basePath || 'none',
103
+ }),
104
+ )
105
+
106
+ // Process all routes with timing, collecting both successes and failures
107
+ const [duration, results] = yield* Effect.forEach(
108
+ routes,
109
+ (route, index) =>
110
+ processRoute(route, serverPort, outputDir, basePath).pipe(
111
+ Effect.tap(() =>
112
+ // Log progress every 5 routes
113
+ (index + 1) % 5 === 0 || index === routes.length - 1
114
+ ? Effect.logDebug(`Progress: ${index + 1}/${routes.length} routes processed`)
115
+ : Effect.void
116
+ ),
117
+ Effect.either, // Convert to Either to capture both success and failure
118
+ ),
119
+ { concurrency: 1 }, // Process sequentially to avoid overwhelming the server
120
+ ).pipe(Effect.timed)
121
+
122
+ // Partition results into successes and failures
123
+ const [failures, successes] = Array.partition(results, Either.isRight)
124
+ const processedCount = successes.length
92
125
 
93
126
  const result: GenerateResult = {
94
- success: true,
127
+ success: failures.length === 0,
95
128
  processedCount,
96
- duration: Date.now() - startTime,
129
+ duration: Duration.toMillis(duration),
97
130
  memoryUsed: process.memoryUsage().heapUsed,
131
+ ...(failures.length > 0 && {
132
+ error: `Failed to generate ${failures.length} out of ${routes.length} routes`,
133
+ failures: failures.map((f) => ({
134
+ route: f.left.route,
135
+ error: f.left.cause instanceof Error ? f.left.cause.message : String(f.left.cause),
136
+ })),
137
+ }),
98
138
  }
99
139
 
100
- debug(`Batch generation complete`, result)
140
+ yield* Effect.logDebug(`Batch generation complete`).pipe(
141
+ Effect.annotateLogs(result),
142
+ )
143
+
101
144
  return result
102
- }).pipe(
103
- Effect.catchAll((error) =>
104
- Effect.succeed({
105
- success: false,
106
- processedCount: 0,
107
- duration: Date.now() - Date.now(),
108
- memoryUsed: process.memoryUsage().heapUsed,
109
- error: error instanceof Error ? error.message : String(error),
110
- })
111
- ),
112
- ),
145
+ }),
113
146
  }
114
147
 
115
148
  // ============================================================================
@@ -120,6 +153,9 @@ const handlers = {
120
153
  WorkerRunner.launch(
121
154
  Layer.provide(
122
155
  WorkerRunner.layerSerialized(PageMessage, handlers),
123
- Layer.merge(NodeWorkerRunner.layer, NodeContext.layer),
156
+ Layer.mergeAll(
157
+ NodeWorkerRunner.layer,
158
+ NodeContext.layer,
159
+ ),
124
160
  ),
125
161
  ).pipe(NodeRuntime.runMain)
@@ -2,18 +2,15 @@
2
2
  * Worker that runs a Polen server for SSG.
3
3
  * This is executed in a child process using Effect Worker API.
4
4
  */
5
- import { debugPolen } from '#singletons/debug'
6
5
  import { WorkerRunner } from '@effect/platform'
7
6
  import { NodeRuntime, NodeWorkerRunner } from '@effect/platform-node'
8
- import { Duration, Effect, Layer, Scope } from 'effect'
7
+ import { Duration, Effect, Layer, Ref, Scope } from 'effect'
9
8
  import { spawn } from 'node:child_process'
10
9
  import type { ChildProcess } from 'node:child_process'
11
10
  import { ServerMessage } from './worker-messages.js'
12
11
 
13
- const debug = debugPolen.sub(`api:ssg:server-runner`)
14
-
15
- // Store the server process for cleanup
16
- let serverProcess: ChildProcess | null = null
12
+ // Store the server process reference for cleanup
13
+ const serverProcessRef = Ref.unsafeMake<ChildProcess | null>(null)
17
14
 
18
15
  // ============================================================================
19
16
  // Handlers
@@ -23,20 +20,21 @@ const handlers = {
23
20
  StartServer: ({ serverPath, port }: { serverPath: string; port: number }) =>
24
21
  Effect.gen(function*() {
25
22
  // If there's already a server running, stop it first
26
- if (serverProcess) {
27
- serverProcess.kill('SIGTERM')
23
+ const existingProcess = yield* Ref.get(serverProcessRef)
24
+ if (existingProcess) {
25
+ existingProcess.kill('SIGTERM')
28
26
  yield* Effect.sleep(Duration.millis(500))
29
- if (!serverProcess.killed) {
30
- serverProcess.kill('SIGKILL')
27
+ if (!existingProcess.killed) {
28
+ existingProcess.kill('SIGKILL')
31
29
  }
32
- serverProcess = null
30
+ yield* Ref.set(serverProcessRef, null)
33
31
  }
34
32
 
35
33
  // Start the server process
36
- const startServer = Effect.sync((): ChildProcess => {
37
- debug(`Starting server with command: node ${serverPath}`)
34
+ yield* Effect.logDebug(`Starting server with command: node ${serverPath}`)
38
35
 
39
- serverProcess = spawn('node', [serverPath], {
36
+ const proc = yield* Effect.sync(() => {
37
+ const serverProc = spawn('node', [serverPath], {
40
38
  env: {
41
39
  ...process.env,
42
40
  PORT: port.toString(),
@@ -44,18 +42,23 @@ const handlers = {
44
42
  stdio: ['ignore', 'pipe', 'pipe'],
45
43
  })
46
44
 
47
- serverProcess.stdout?.on('data', (data) => {
48
- debug(`[Server ${port}] stdout`, data.toString().trim())
45
+ // Log server output
46
+ serverProc.stdout?.on('data', (data) => {
47
+ Effect.logDebug(`[Server ${port}] stdout: ${data.toString().trim()}`).pipe(
48
+ Effect.runSync,
49
+ )
49
50
  })
50
51
 
51
- serverProcess.stderr?.on('data', (data) => {
52
- debug(`[Server ${port}] stderr`, data.toString().trim())
52
+ serverProc.stderr?.on('data', (data) => {
53
+ Effect.logDebug(`[Server ${port}] stderr: ${data.toString().trim()}`).pipe(
54
+ Effect.runSync,
55
+ )
53
56
  })
54
57
 
55
- return serverProcess
58
+ return serverProc
56
59
  })
57
60
 
58
- const proc: ChildProcess = yield* startServer
61
+ yield* Ref.set(serverProcessRef, proc)
59
62
 
60
63
  // Wait for server to be ready with proper interruption support
61
64
  const waitForReady = Effect.async<void>((resume) => {
@@ -80,7 +83,7 @@ const handlers = {
80
83
  try {
81
84
  const response = await fetch(`http://localhost:${port}/`)
82
85
  if (response.ok || response.status === 404) {
83
- debug(`[Server ${port}] Ready!`)
86
+ Effect.logDebug(`[Server ${port}] Ready!`).pipe(Effect.runSync)
84
87
  if (checkInterval) clearInterval(checkInterval)
85
88
  proc.removeListener('error', errorHandler)
86
89
  proc.removeListener('exit', exitHandler)
@@ -114,33 +117,25 @@ const handlers = {
114
117
  Effect.die(new Error(`Server on port ${port} failed to start within 30 seconds`))),
115
118
  )
116
119
 
117
- // Add finalizer to ensure cleanup
118
- yield* Effect.addFinalizer(() =>
119
- Effect.sync(() => {
120
- debug(`Finalizer: Stopping server on port ${port}`)
121
- if (serverProcess && !serverProcess.killed) {
122
- serverProcess.kill('SIGTERM')
123
- // Try to kill forcefully after a brief wait
124
- setTimeout(() => {
125
- if (serverProcess && !serverProcess.killed) {
126
- serverProcess.kill('SIGKILL')
127
- }
128
- serverProcess = null
129
- }, 100)
130
- }
131
- })
132
- )
133
- }).pipe(Effect.scoped),
134
- StopServer: () =>
120
+ yield* Effect.logDebug(`Server on port ${port} started successfully`)
121
+ }),
122
+ StopServer: ({ port }: { port?: number | undefined }) =>
135
123
  Effect.gen(function*() {
136
- if (serverProcess) {
137
- serverProcess.kill('SIGTERM')
124
+ const serverProc = yield* Ref.get(serverProcessRef)
125
+ if (serverProc) {
126
+ if (port !== undefined) {
127
+ yield* Effect.logDebug(`Stopping server on port ${port}`)
128
+ }
129
+ serverProc.kill('SIGTERM')
138
130
  // Give it time to shut down gracefully
139
131
  yield* Effect.sleep(Duration.millis(500))
140
- if (!serverProcess.killed) {
141
- serverProcess.kill('SIGKILL')
132
+ if (!serverProc.killed) {
133
+ serverProc.kill('SIGKILL')
134
+ }
135
+ yield* Ref.set(serverProcessRef, null)
136
+ if (port !== undefined) {
137
+ yield* Effect.logDebug(`Server on port ${port} stopped`)
142
138
  }
143
- serverProcess = null
144
139
  }
145
140
  }),
146
141
  }
@@ -16,6 +16,14 @@ const GenerateResultSchema = S.Struct({
16
16
  duration: S.Number,
17
17
  memoryUsed: S.Number,
18
18
  error: S.optional(S.String),
19
+ failures: S.optional(
20
+ S.Array(
21
+ S.Struct({
22
+ route: S.String,
23
+ error: S.String,
24
+ }),
25
+ ),
26
+ ),
19
27
  })
20
28
 
21
29
  export type GenerateResult = S.Schema.Type<typeof GenerateResultSchema>
@@ -40,7 +48,9 @@ export class StopServerMessage extends S.TaggedRequest<StopServerMessage>()(
40
48
  'StopServer',
41
49
  {
42
50
  failure: S.Never,
43
- payload: {},
51
+ payload: {
52
+ port: S.optional(S.Number),
53
+ },
44
54
  success: S.Void,
45
55
  },
46
56
  ) {}
@@ -60,6 +70,7 @@ export class GeneratePagesMessage extends S.TaggedRequest<GeneratePagesMessage>(
60
70
  routes: S.Array(RouteSchema),
61
71
  serverPort: S.Number,
62
72
  outputDir: S.String,
73
+ basePath: S.optional(S.String),
63
74
  },
64
75
  success: GenerateResultSchema,
65
76
  },
@@ -2,7 +2,7 @@ import type { Api } from '#api/$'
2
2
  import type { Vite } from '#dep/vite/index'
3
3
  import { Manifest } from '#vite/plugins/manifest'
4
4
  import { vitePluginSsrCss } from '@hiogawa/vite-plugin-ssr-css'
5
- import ViteReact from '@vitejs/plugin-react-oxc'
5
+ import ViteReact from '@vitejs/plugin-react'
6
6
  import { Path } from '@wollybeard/kit'
7
7
  import Inspect from 'vite-plugin-inspect'
8
8
  import { Branding } from './branding.js'
@@ -3,6 +3,7 @@ import { Vite } from '#dep/vite/index'
3
3
  import { Catalog } from '#lib/catalog/$'
4
4
  import { FileRouter } from '#lib/file-router/$'
5
5
  import { SchemaDefinition } from '#lib/schema-definition/$'
6
+ import { Version } from '#lib/version/$'
6
7
  import { debugPolen } from '#singletons/debug'
7
8
  import * as NodeFileSystem from '@effect/platform-node/NodeFileSystem'
8
9
  import consola from 'consola'
@@ -102,12 +103,14 @@ function processVersionedCatalog(
102
103
  ): void {
103
104
  for (const schema of Catalog.Versioned.getAll(catalog)) {
104
105
  const version = schema.version
105
- routes.push(`/reference/version/${version}`)
106
+ // Properly encode the version to its string representation
107
+ const versionValue = Version.encodeSync(version)
108
+ routes.push(`/reference/version/${versionValue}`)
106
109
 
107
110
  processSchemaDefinition(
108
111
  schema.definition,
109
112
  routes,
110
- `/reference/version/${version}`,
113
+ `/reference/version/${versionValue}`,
111
114
  )
112
115
  }
113
116
  }